diff --git a/book/src/advanced/ui.layout.md b/book/src/advanced/ui.layout.md index 96fac6ff4..190e411a1 100644 --- a/book/src/advanced/ui.layout.md +++ b/book/src/advanced/ui.layout.md @@ -158,6 +158,47 @@ Setting the color is done by adding this to a layout: static constexpr auto background() { enum { dark } d{}; return d; } ``` +### Alignment + +By default, a grid makes every cell as large as its largest item and centres +each item in its cell, a table centres each cell in its column, and box layouts +put their children against the left edge. When items have different widths, +their edges do not line up. An `alignment()` property on a layout chooses where +its children sit instead: + +```cpp +static constexpr auto alignment() { enum { left, center, right } d{}; return d; } + +// or, with the helpers: +halp_meta(alignment, halp::alignments::left) +``` + +| Layout | Effect | +|-----------------|-------------------------------------------------------------------------------------------| +| `grid` | Each column is as wide as its widest item, each row as tall as its tallest; items sit at the left, centre or right of their column, still centred in their row. | +| `table` | Cells and column titles sit at the left, centre or right of their column. | +| `vbox`, `section` | Children sit at the left, centre or right of the column, as wide as the widest child or as the box was stretched to. | +| `hbox`, `group` | The row of children as a whole sits at the left, centre or right of the box, when a parent stretched it wider. | + +Other layouts (`tabs`, `split`, `container`) ignore it; set it on the pages +or the boxes inside them. + +Alignment is not inherited: it applies to the children of the layout that +declares it, and a nested layout uses its own. A table's rows always follow +the table's columns. + +```cpp +struct { + halp_meta(layout, halp::layouts::grid) + halp_meta(columns, 3) + halp_meta(alignment, halp::alignments::left) + halp::item<&ins::record> record; + halp::item<&ins::overdub> overdub; + halp::item<&ins::stop> stop; + halp::item<&ins::undo> undo; +} buttons; +``` + ### Explicit positioning In "group" or "container" layouts, widgets will not be positioned automatically. `x` and `y` methods can be used for that. diff --git a/book/src/advanced/ui.painting.md b/book/src/advanced/ui.painting.md index 6637fdc7e..2f57cb6c9 100644 --- a/book/src/advanced/ui.painting.md +++ b/book/src/advanced/ui.painting.md @@ -163,6 +163,43 @@ struct custom_item }; ``` +## Dragging files out, and placing clips in the timeline +A custom item can hand files to the host. With a `drag_payload` method, the +host calls it once the mouse has moved far enough with the button held, and +starts a drag of the files it returns, as a file manager would: + +```cpp +halp::drag_payload drag_payload(double x, double y) +{ + halp::drag_payload p; + p.files = {"/path/to/take.wav"}; + return p; +} +``` + +With a `send_to_timeline` member, which the host fills when it can place +clips next to the process, the item asks for files to be put in the +timeline at a date: + +```cpp +std::function send_to_timeline; + +void send() +{ + if(send_to_timeline) + send_to_timeline(halp::timeline_clip{ + .files = {"/path/to/take.wav"}, + .date = 4.0, // seconds from the start of the process + .name = "take", + .stretch = halp::clip_stretch::timestretch}); +} +``` + +`stretch` says how the audio files follow the tempo of the host: `none` +(they play at the speed they were recorded at), `repitch` or `repitch_fast` +(resampled, the pitch follows), `timestretch` or `timestretch_percussive` +(the pitch is kept). The default, `automatic`, leaves the choice to the host. + ## Painter API Here is the complete supported API so far: diff --git a/book/src/writing_processors/midi.md b/book/src/writing_processors/midi.md index f7e4b373f..684ebb565 100644 --- a/book/src/writing_processors/midi.md +++ b/book/src/writing_processors/midi.md @@ -77,23 +77,27 @@ A one-sample note starting on the last frame therefore releases at frame zero of the next writable invocation. A zero-frame invocation cannot publish that release. The ossia graph initializes native ports, runs all requested token slices, then -publishes the node's aggregate output. Processors that need all slices preserved -can opt in: +publishes the node's aggregate output. A buffer holds several slices when a +process loops inside it, or when a process stopped inside it runs its last +partial slice and then an empty one. The binding keeps the MIDI of all the +slices of a buffer: it stores the already-produced packets in executor-local +storage and rebuilds the native output from them after each slice, even an empty +one. The `ossia::graph_node::begin_execution()` hook resets this storage before +each graph execution, independently of seeks, repeated positions, callback +counters, and buffer sizes. Direct users of `safe_node::run()` must call +`begin_execution()` once before each group of slices they will publish together. + +The storage of each MIDI output, and the native output port, reserve the buffer +size in packets. A processor that sends more can raise it, or go back to each +slice replacing the output of the previous one: ```cpp -halp_meta(local_midi_tick_batch, true) -halp_meta(local_midi_tick_batch_reserve, 4096) // Optional per-MIDI-port reserve. +halp_meta(local_midi_tick_batch_reserve, 4096) // Per-MIDI-port reserve. +halp_meta(local_midi_tick_batch, false) // Per-slice replacement. ``` -This stores only already-produced packets in executor-local storage. The -`ossia::graph_node::begin_execution()` hook resets it before each graph execution, -independently of seeks, repeated positions, callback counters, and buffer sizes. -The native output is rebuilt from the batch even for empty slices. Bindings -without the opt-in retain per-slice replacement; an explicit `false` disables -batching. Direct users of `safe_node::run()` must call `begin_execution()` once -before each group of slices they will publish together. This requires a libossia -version providing that graph hook. A reserve is a capacity hint, not a bound or -an allocation-free guarantee for arbitrary host traffic. +A reserve is a capacity hint, not a bound or an allocation-free guarantee for +arbitrary host traffic. A lifecycle callback such as `stop()` is not itself a writable publication window. A host must run and publish a final release-only invocation before diff --git a/book/src/writing_processors/ports.metadatas.widget.md b/book/src/writing_processors/ports.metadatas.widget.md index 6dde91eef..55a0e61bf 100644 --- a/book/src/writing_processors/ports.metadatas.widget.md +++ b/book/src/writing_processors/ports.metadatas.widget.md @@ -39,10 +39,73 @@ xy, xy_spinbox, xyz, xyz_spinbox, color, time_chooser, +grid_chooser, hbargraph, vbargraph, bargraph, range_slider, range_spinbox ``` This kind of widget definition is here to enable host DAWs to automatically generate appropriate UIs automatically. +## Time and grid choosers + +`halp::time_chooser` is a duration: a knob whose readout switches between free +seconds and a note value synced to the tempo. The object only sees seconds (and +a `sync` flag): the host recomputes them when the tempo changes. + +```cpp +halp::time_chooser<"Delay", halp::range{.min = 0.001, .max = 5., .init = 0.25}> delay; +``` + +`halp::grid_chooser` (``) is a quantization grid. Its +value is musical and the object receives it as is, a `halp::grid_value`: + +```cpp +struct grid_value +{ + grid_unit unit; // free, note, bars, period (and parent, reserved) + double value; + double offset; // reserved +}; +``` + +| unit | value | e.g. | +|----------|-----------------------------------------|--------------------------| +| `free` | seconds; 0 means no quantization | `0.5 s`, `None` | +| `note` | fraction of a whole note (4 quarters) | `1/8T` is 1/12, `1/8.` is 3/16, `2/1` is 2 | +| `bars` | bars of the current signature | `1 bar`, `3 bars` | +| `period` | multiple of the object's own period | `Loop`, `2x loop`, `1/2 loop` | + +A note value is always that many whole notes, never a bar: `1/1` is four +quarters in 3/4 too. The object declares which units the user may pick, the +default, the longest grid in bars and the name of its period: + +```cpp +halp::grid_chooser< + "Quantize", + halp::grid_setup{ + .units = halp::grid_units::musical | halp::grid_units::period, + .init = halp::grid_value::bars(1), + .max_bars = 8}, + "loop"> + quantize; +``` + +`halp::next_grid_point` (``) finds where the next point of a +grid falls in the current buffer, from a `halp::tick_musical` and, for the +`period` unit, the object's period length and position in frames. It does not +allocate and can be called from the audio thread: + +```cpp +void operator()(halp::tick_musical tk) +{ + const halp::grid_period loop{.length = loop_frames, .position = played_frames}; + if(auto frame = halp::next_grid_point(inputs.quantize.value, tk, sample_rate, loop)) + start_at(*frame); +} +``` + +Notes shorter than a bar restart at every bar line; bars and longer notes +count from the last signature change; free times and periods count from the +period's origin (or the start of playback, for free times without a period). + A further chapter will present how to create entirely custom painted UIs and widgets. \ No newline at end of file diff --git a/examples/Advanced/MidiScaler/MidiCommon/ChannelAllocator.hpp b/examples/Advanced/MidiScaler/MidiCommon/ChannelAllocator.hpp new file mode 100644 index 000000000..9588725db --- /dev/null +++ b/examples/Advanced/MidiScaler/MidiCommon/ChannelAllocator.hpp @@ -0,0 +1,177 @@ +#pragma once + +/* SPDX-License-Identifier: GPL-3.0-or-later */ + +#include +#include +#include +#include + +namespace mtk +{ +/** + * @brief Gives each new note a MIDI channel of its own, from a set of + * channels, so that per-channel messages (pitch bend, pressure) only affect + * that note. + * + * A channel is free when none of its notes is held and its last note ended + * at least `release_reserve` frames ago, so that the release tail of a + * synth voice does not get the next note's pitch bend. + */ +struct channel_allocator +{ + enum class mode : uint8_t + { + round_robin, //!< next free channel after the last one used, wrapping + ping_pong, //!< as round robin, reversing direction at the ends + lowest, //!< lowest free channel + random //!< any free channel + }; + + //! Bit n set: channel n + 1 can be used. + void set_channels(uint16_t mask) noexcept { m_mask = mask; } + uint16_t channels() const noexcept { return m_mask; } + void set_mode(mode m) noexcept { m_mode = m; } + void set_release_reserve(int64_t frames) noexcept { m_reserve = frames; } + + bool usable(int ch) const noexcept { return ch >= 1 && ch <= 16 && (m_mask >> (ch - 1)) & 1; } + + bool is_free(int ch, int64_t now) const noexcept + { + const auto& c = m_ch[ch - 1]; + return c.held == 0 && (now - c.ended_at) >= m_reserve; + } + + //! A free channel, or nothing when every usable channel is taken. + //! `random01` draws in [0, 1) and is only used in random mode. + template + std::optional allocate(int64_t now, Random&& random01) noexcept + { + int candidates[16]; + int n = 0; + for(int ch = 1; ch <= 16; ch++) + if(usable(ch) && is_free(ch, now)) + candidates[n++] = ch; + if(n == 0) + return std::nullopt; + + int chosen = candidates[0]; + switch(m_mode) + { + case mode::lowest: + break; + case mode::random: + chosen = candidates[std::min(n - 1, int(random01() * n))]; + break; + case mode::round_robin: + chosen = next_after(candidates, n, m_last, +1, true); + break; + case mode::ping_pong: { + int c = next_after(candidates, n, m_last, m_dir, false); + if(c == 0) + { + m_dir = -m_dir; + c = next_after(candidates, n, m_last, m_dir, false); + } + chosen = c != 0 ? c : candidates[0]; + break; + } + } + + take(chosen); + return chosen; + } + + std::optional allocate(int64_t now) noexcept + { + return allocate(now, [] { return 0.; }); + } + + //! Marks a channel as playing one more note, whether it was allocated here + //! or chosen by the caller (a surplus channel, say). + void take(int ch) noexcept + { + if(ch < 1 || ch > 16) + return; + m_ch[ch - 1].held++; + m_last = ch; + } + + void release(int ch, int64_t now) noexcept + { + if(ch < 1 || ch > 16) + return; + auto& c = m_ch[ch - 1]; + if(c.held > 0) + c.held--; + if(c.held == 0) + c.ended_at = now; + } + + int held(int ch) const noexcept { return m_ch[(ch - 1) & 15].held; } + + //! The usable channel with no note held whose last note ended first, even + //! if it is still in its release reserve; nothing if every one holds a note. + std::optional oldest_released() const noexcept + { + std::optional best; + for(int ch = 1; ch <= 16; ch++) + { + if(!usable(ch) || m_ch[ch - 1].held != 0) + continue; + if(!best || m_ch[ch - 1].ended_at < m_ch[*best - 1].ended_at) + best = ch; + } + return best; + } + + //! The n-th usable channel (0: the lowest), or nothing. + std::optional nth_usable(int n) const noexcept + { + for(int ch = 1; ch <= 16; ch++) + if(usable(ch) && n-- == 0) + return ch; + return std::nullopt; + } + + void reset() noexcept + { + m_ch = {}; + m_last = 0; + m_dir = 1; + } + +private: + //! The first candidate after `from` in direction `dir`; with `wrap`, starts + //! over from the other end. Returns 0 when there is none. + static int next_after(const int* cands, int n, int from, int dir, bool wrap) noexcept + { + if(dir > 0) + { + for(int i = 0; i < n; i++) + if(cands[i] > from) + return cands[i]; + return wrap ? cands[0] : 0; + } + else + { + for(int i = n - 1; i >= 0; i--) + if(cands[i] < from) + return cands[i]; + return wrap ? cands[n - 1] : 0; + } + } + + struct channel_state + { + int held{}; + int64_t ended_at{INT64_MIN / 2}; + }; + std::array m_ch{}; + uint16_t m_mask{0xFFFF}; + int64_t m_reserve{}; + mode m_mode{mode::round_robin}; + int m_last{0}; + int m_dir{1}; +}; +} diff --git a/examples/Advanced/MidiScaler/MidiCommon/EventQueue.hpp b/examples/Advanced/MidiScaler/MidiCommon/EventQueue.hpp new file mode 100644 index 000000000..fb09a62ea --- /dev/null +++ b/examples/Advanced/MidiScaler/MidiCommon/EventQueue.hpp @@ -0,0 +1,177 @@ +#pragma once + +/* SPDX-License-Identifier: GPL-3.0-or-later */ + +#include +#include + +#include +#include +#include + +namespace mtk +{ +//! Frames covered by a duration in quarter notes at a given tempo. +inline int64_t quarters_to_frames(double quarters, double tempo, double rate) noexcept +{ + if(tempo <= 0.) + tempo = 120.; + return int64_t(quarters * 60. / tempo * rate + 0.5); +} + +inline int64_t seconds_to_frames(double seconds, double rate) noexcept +{ + return int64_t(seconds * rate + 0.5); +} + +/** + * @brief MIDI messages waiting to be sent, dated in absolute frames. + * + * The owner keeps a frame counter `now` that advances by the tick's frame + * count. Messages with the same date keep their scheduling order. + * Fixed capacity: when full, a note-off evicts the latest other message, + * since a lost note-off hangs a note while a lost note-on does not. + */ +template +struct event_queue +{ + struct event + { + int64_t at{}; + libremidi::message msg; + //! Free for the owner, e.g. to know which input note an event belongs to. + uint32_t tag{}; + }; + + //! False when the queue is full and `m` is not a note-off: it is dropped. + bool schedule(int64_t at, const libremidi::message& m, uint32_t tag = 0) noexcept + { + if(m_size == Capacity) + { + if(!is_note_off(m)) + return false; + int victim = -1; + for(int i = m_size - 1; i >= 0; i--) + { + if(!is_note_off(m_events[i].msg)) + { + victim = i; + break; + } + } + if(victim < 0) + victim = m_size - 1; + erase(victim); + } + + int pos = m_size; + while(pos > 0 && m_events[pos - 1].at > at) + pos--; + for(int i = m_size; i > pos; i--) + m_events[i] = std::move(m_events[i - 1]); + m_events[pos].at = at; + m_events[pos].msg = m; + m_events[pos].msg.timestamp = 0; + m_events[pos].tag = tag; + m_size++; + return true; + } + + //! When full, removes the latest event for which `pred` is true. Returns + //! whether there is room for one more event. + template + bool make_room(Pred&& pred) noexcept + { + if(m_size < Capacity) + return true; + for(int i = m_size - 1; i >= 0; i--) + { + if(pred(m_events[i])) + { + erase(i); + return true; + } + } + return false; + } + + //! Sends every message due before `now + frames`, with its offset in the + //! tick as timestamp. Messages due in the past go out at offset 0. + //! `send` takes the message, and optionally the event's tag. + template + void release(int64_t now, int frames, Send&& send) noexcept + { + if(frames <= 0) + return; + const int64_t limit = now + frames; + int n = 0; + while(n < m_size && m_events[n].at < limit) + { + auto msg = m_events[n].msg; + msg.timestamp = std::clamp(m_events[n].at - now, 0, frames - 1); + if constexpr(requires { send(std::move(msg), m_events[n].tag); }) + send(std::move(msg), m_events[n].tag); + else + send(std::move(msg)); + n++; + } + if(n > 0) + { + for(int i = n; i < m_size; i++) + m_events[i - n] = std::move(m_events[i]); + m_size -= n; + } + } + + //! Sends the queued note-offs and drops everything else: what a stop or an + //! all-notes-off needs so that nothing already started keeps sounding. + template + void flush_note_offs(int64_t ts, Send&& send) noexcept + { + for(int i = 0; i < m_size; i++) + { + if(is_note_off(m_events[i].msg)) + { + auto msg = m_events[i].msg; + msg.timestamp = ts; + send(std::move(msg)); + } + } + m_size = 0; + } + + //! Removes the events for which `pred` is true. + template + void remove_if(Pred&& pred) noexcept + { + int w = 0; + for(int r = 0; r < m_size; r++) + { + if(!pred(m_events[r])) + { + if(w != r) + m_events[w] = std::move(m_events[r]); + w++; + } + } + m_size = w; + } + + void clear() noexcept { m_size = 0; } + int size() const noexcept { return m_size; } + bool empty() const noexcept { return m_size == 0; } + bool full() const noexcept { return m_size == Capacity; } + const event& operator[](int i) const noexcept { return m_events[i]; } + +private: + void erase(int i) noexcept + { + for(int k = i; k + 1 < m_size; k++) + m_events[k] = std::move(m_events[k + 1]); + m_size--; + } + + std::array m_events{}; + int m_size{}; +}; +} diff --git a/examples/Advanced/MidiScaler/MidiCommon/Messages.hpp b/examples/Advanced/MidiScaler/MidiCommon/Messages.hpp new file mode 100644 index 000000000..6c26d6754 --- /dev/null +++ b/examples/Advanced/MidiScaler/MidiCommon/Messages.hpp @@ -0,0 +1,385 @@ +#pragma once + +/* SPDX-License-Identifier: GPL-3.0-or-later */ + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace mtk +{ +//! Every kind of MIDI 1 message an object may want to select. +enum class midi_kind : uint8_t +{ + note_on, + note_off, + control_change, + program_change, + pitch_bend, + channel_pressure, + poly_pressure, + sysex, + time_code, + song_position, + song_select, + tune_request, + clock, + start, + continue_, + stop, + active_sensing, + reset, + other +}; + +inline constexpr int midi_kind_count = int(midi_kind::other) + 1; + +//! A note-on with velocity 0 is a note-off. +inline midi_kind classify(const libremidi::message& m) noexcept +{ + if((m.size() == 0)) + return midi_kind::other; + const uint8_t s = m.bytes[0]; + if(s < 0x80) + return midi_kind::other; + if(s < 0xF0) + { + // A truncated channel message carries no usable data byte. + const std::size_t expected = ((s & 0xF0) == 0xC0 || (s & 0xF0) == 0xD0) ? 2 : 3; + if(m.size() < expected) + return midi_kind::other; + switch(s & 0xF0) + { + case 0x80: + return midi_kind::note_off; + case 0x90: + return m.bytes[2] == 0 ? midi_kind::note_off : midi_kind::note_on; + case 0xA0: + return midi_kind::poly_pressure; + case 0xB0: + return midi_kind::control_change; + case 0xC0: + return midi_kind::program_change; + case 0xD0: + return midi_kind::channel_pressure; + case 0xE0: + return midi_kind::pitch_bend; + default: + return midi_kind::other; + } + } + switch(s) + { + case 0xF0: + return midi_kind::sysex; + case 0xF1: + return midi_kind::time_code; + case 0xF2: + return midi_kind::song_position; + case 0xF3: + return midi_kind::song_select; + case 0xF6: + return midi_kind::tune_request; + case 0xF8: + return midi_kind::clock; + case 0xFA: + return midi_kind::start; + case 0xFB: + return midi_kind::continue_; + case 0xFC: + return midi_kind::stop; + case 0xFE: + return midi_kind::active_sensing; + case 0xFF: + return midi_kind::reset; + default: + return midi_kind::other; + } +} + +inline bool is_channel_message(const libremidi::message& m) noexcept +{ + return !(m.size() == 0) && m.bytes[0] >= 0x80 && m.bytes[0] < 0xF0; +} + +inline bool is_note_on(const libremidi::message& m) noexcept +{ + return m.size() >= 3 && (m.bytes[0] & 0xF0) == 0x90 && m.bytes[2] != 0; +} + +inline bool is_note_off(const libremidi::message& m) noexcept +{ + if(m.size() < 3) + return false; + const uint8_t t = m.bytes[0] & 0xF0; + return t == 0x80 || (t == 0x90 && m.bytes[2] == 0); +} + +//! 1 to 16, or 0 for system messages. +inline int channel_of(const libremidi::message& m) noexcept +{ + return is_channel_message(m) ? (m.bytes[0] & 0x0F) + 1 : 0; +} + +inline void set_channel(libremidi::message& m, int channel) noexcept +{ + if(is_channel_message(m)) + m.bytes[0] = uint8_t((m.bytes[0] & 0xF0) | ((channel - 1) & 0x0F)); +} + +/** + * @brief The value a message carries, in its natural range, or nothing for a + * message that has none: + * - note on, note off, poly pressure and CC: the second data byte, 0 - 127 + * (velocity, pressure, controller value); + * - program change, channel pressure, MTC quarter frame and song select: the + * first data byte, 0 - 127; + * - pitch bend: -1 to 1, 0 at the center (8192), -1 at 0 and 1 at 16383; + * - song position: 0 - 16383, in sixteenth notes; + * - sysex, tune request and the real-time messages: nothing. + */ +inline std::optional message_value(const libremidi::message& m) noexcept +{ + using enum midi_kind; + const auto byte = [&](std::size_t i) { return m.size() > i ? float(m.bytes[i]) : 0.f; }; + switch(classify(m)) + { + case note_on: + case note_off: + case poly_pressure: + case control_change: + return byte(2); + case program_change: + case channel_pressure: + case time_code: + case song_select: + return byte(1); + case pitch_bend: { + const int v = (int(byte(2)) << 7) | int(byte(1)); + return v < 8192 ? float(v - 8192) / 8192.f : float(v - 8192) / 8191.f; + } + case song_position: + return float((int(byte(2)) << 7) | int(byte(1))); + default: + return std::nullopt; + } +} + +//! CC 120 (all sound off) and CC 123 (all notes off). +inline bool is_all_notes_off(const libremidi::message& m) noexcept +{ + return m.size() >= 3 && (m.bytes[0] & 0xF0) == 0xB0 + && (m.bytes[1] == 120 || m.bytes[1] == 123); +} + +//! "C4" is 60, "C-1" is 0, "G9" is 127. Accepts '#' and 'b'. +//! Returns the note and the number of characters read. +inline std::optional> +parse_note_name(std::string_view s) noexcept +{ + if(s.empty()) + return std::nullopt; + + static constexpr int pcs[7] = {9, 11, 0, 2, 4, 5, 7}; // A B C D E F G + char c = s[0]; + if(c >= 'a' && c <= 'g') + c = char(c - 'a' + 'A'); + if(c < 'A' || c > 'G') + return std::nullopt; + + int pc = pcs[c - 'A']; + std::size_t i = 1; + if(i < s.size() && (s[i] == '#' || s[i] == 'b')) + { + pc += (s[i] == '#') ? 1 : -1; + i++; + } + + bool negative = false; + if(i < s.size() && s[i] == '-') + { + negative = true; + i++; + } + if(i >= s.size() || s[i] < '0' || s[i] > '9') + return std::nullopt; + + int octave = 0; + while(i < s.size() && s[i] >= '0' && s[i] <= '9') + { + octave = octave * 10 + (s[i] - '0'); + i++; + if(octave > 20) + return std::nullopt; + } + if(negative) + octave = -octave; + + const int note = (octave + 1) * 12 + pc; + if(note < 0 || note > 127) + return std::nullopt; + return std::make_pair(note, i); +} + +/** + * @brief A set of inclusive integer ranges written as text: "1-4, 6, 9-16". + * + * With note names enabled, bounds may also be written "C-1-B2, C4, F#5". + * An empty text matches nothing. Parsing never allocates. + */ +struct range_list +{ + static constexpr int max_ranges = 16; + + struct range + { + int lo{}, hi{}; + }; + + std::array ranges{}; + int count{}; + + bool contains(int v) const noexcept + { + for(int i = 0; i < count; i++) + if(v >= ranges[i].lo && v <= ranges[i].hi) + return true; + return false; + } + + bool empty() const noexcept { return count == 0; } + + //! On error, the list is left empty and the position of the faulty + //! character is returned. + std::optional + parse(std::string_view s, int min, int max, bool note_names = false) noexcept + { + count = 0; + std::size_t i = 0; + + auto skip_spaces = [&] { + while(i < s.size() && (s[i] == ' ' || s[i] == '\t')) + i++; + }; + + auto read_bound = [&]() -> std::optional { + skip_spaces(); + if(i >= s.size()) + return std::nullopt; + if(s[i] >= '0' && s[i] <= '9') + { + int v = 0; + while(i < s.size() && s[i] >= '0' && s[i] <= '9') + { + v = v * 10 + (s[i] - '0'); + if(v > 100000) + return std::nullopt; + i++; + } + return v; + } + if(note_names) + { + if(auto n = parse_note_name(s.substr(i))) + { + i += n->second; + return n->first; + } + } + return std::nullopt; + }; + + skip_spaces(); + if(i == s.size()) + return std::nullopt; + + while(true) + { + const std::size_t start = i; + auto lo = read_bound(); + if(!lo) + { + count = 0; + return std::max(start, i); + } + int hi = *lo; + skip_spaces(); + if(i < s.size() && s[i] == '-') + { + i++; + const std::size_t hpos = i; + auto h = read_bound(); + if(!h) + { + count = 0; + return hpos; + } + hi = *h; + } + if(*lo > hi) + std::swap(*lo, hi); + if(*lo < min || hi > max || count >= max_ranges) + { + count = 0; + return start; + } + ranges[count++] = {*lo, hi}; + + skip_spaces(); + if(i == s.size()) + return std::nullopt; + if(s[i] != ',') + { + count = 0; + return i; + } + i++; + } + } +}; + +/** + * @brief Keeps a range_list in sync with a text control without allocating. + * + * The text is compared with the last one parsed, kept in a fixed buffer; it is + * only parsed again when it changed. + */ +struct cached_range_list +{ + static constexpr std::size_t max_text = 256; + + range_list list; + bool valid{true}; + + const range_list& update(std::string_view text, int min, int max, bool notes = false) noexcept + { + if(text.size() > max_text) + text = text.substr(0, max_text); + if(m_parsed && text == std::string_view{m_text.data(), m_size} && min == m_min + && max == m_max && notes == m_notes) + return list; + + std::copy(text.begin(), text.end(), m_text.begin()); + m_size = text.size(); + m_min = min; + m_max = max; + m_notes = notes; + m_parsed = true; + valid = !list.parse(text, min, max, notes).has_value(); + return list; + } + +private: + std::array m_text{}; + std::size_t m_size{}; + int m_min{}, m_max{}; + bool m_notes{}; + bool m_parsed{}; +}; +} diff --git a/examples/Advanced/MidiScaler/MidiCommon/NoteTracker.hpp b/examples/Advanced/MidiScaler/MidiCommon/NoteTracker.hpp new file mode 100644 index 000000000..b06689101 --- /dev/null +++ b/examples/Advanced/MidiScaler/MidiCommon/NoteTracker.hpp @@ -0,0 +1,156 @@ +#pragma once + +/* SPDX-License-Identifier: GPL-3.0-or-later */ + +#include +#include + +#include +#include + +namespace mtk +{ +/** + * @brief Remembers which output notes each held input note started, so that + * every note-on an object sends gets exactly one note-off. + * + * Output notes are reference-counted per channel and pitch: when two input + * notes produce the same output note, it is started once and ended when the + * last of them is released, so that releasing one never cuts the other. + * + * Fixed capacity, never allocates: when full, the oldest held note is + * released to make room. + */ +template +struct note_tracker +{ + struct out_note + { + uint8_t channel{}; //!< 1 - 16 + uint8_t pitch{}; + }; + + struct held + { + uint8_t channel{}; //!< input channel, 1 - 16 + uint8_t pitch{}; //!< input pitch + uint8_t count{}; + std::array outputs{}; + }; + + //! Starts tracking an input note. Emits note-offs through `emit` if the + //! oldest note has to be released to make room. + template + held& begin(int channel, int pitch, int64_t ts, Emit&& emit) noexcept + { + if(m_count == MaxHeld) + release_at(0, ts, 0, emit); + + held& h = m_held[m_count++]; + h.channel = uint8_t(channel); + h.pitch = uint8_t(pitch); + h.count = 0; + return h; + } + + //! Sends a note-on for an output note of `h`, unless that note already sounds + //! for another held note. Extra notes beyond MaxOutputs are ignored. + template + void play(held& h, int channel, int pitch, int velocity, int64_t ts, Emit&& emit) noexcept + { + if(pitch < 0 || pitch > 127 || channel < 1 || channel > 16 || h.count >= MaxOutputs) + return; + velocity = velocity < 1 ? 1 : (velocity > 127 ? 127 : velocity); + h.outputs[h.count++] = {uint8_t(channel), uint8_t(pitch)}; + if(m_sounding[channel - 1][pitch]++ == 0) + { + auto m = libremidi::channel_events::note_on(channel, pitch, velocity); + m.timestamp = ts; + emit(std::move(m)); + } + } + + //! Releases the oldest held note matching the input note. Returns false if + //! none matched: the caller usually passes such a note-off through. + template + bool end(int channel, int pitch, int64_t ts, int velocity, Emit&& emit) noexcept + { + for(int i = 0; i < m_count; i++) + { + if(m_held[i].channel == channel && m_held[i].pitch == pitch) + { + release_at(i, ts, velocity, emit); + return true; + } + } + return false; + } + + //! Releases the held note at `index`, as listed by operator[]. + template + void end_at(int index, int64_t ts, int velocity, Emit&& emit) noexcept + { + if(index >= 0 && index < m_count) + release_at(index, ts, velocity, emit); + } + + template + void end_all(int64_t ts, Emit&& emit) noexcept + { + while(m_count > 0) + release_at(m_count - 1, ts, 0, emit); + } + + //! Whether an input note is held. + bool holds(int channel, int pitch) const noexcept + { + for(int i = 0; i < m_count; i++) + if(m_held[i].channel == channel && m_held[i].pitch == pitch) + return true; + return false; + } + + //! The oldest held instance of an input note, or nullptr. + held* find(int channel, int pitch) noexcept + { + for(int i = 0; i < m_count; i++) + if(m_held[i].channel == channel && m_held[i].pitch == pitch) + return &m_held[i]; + return nullptr; + } + + int size() const noexcept { return m_count; } + const held& operator[](int i) const noexcept { return m_held[i]; } + + //! Number of held notes currently producing this output note. + int sounding(int channel, int pitch) const noexcept + { + return m_sounding[(channel - 1) & 15][pitch & 127]; + } + +private: + template + void release_at(int index, int64_t ts, int velocity, Emit& emit) noexcept + { + held& h = m_held[index]; + for(int k = 0; k < h.count; k++) + { + auto& s = m_sounding[h.outputs[k].channel - 1][h.outputs[k].pitch]; + if(s > 0 && --s == 0) + { + auto m = libremidi::channel_events::note_off( + h.outputs[k].channel, h.outputs[k].pitch, velocity); + m.timestamp = ts; + emit(std::move(m)); + } + } + for(int i = index; i + 1 < m_count; i++) + m_held[i] = m_held[i + 1]; + m_count--; + } + + std::array m_held{}; + int m_count{}; + std::array, 16> m_sounding{}; +}; +} diff --git a/examples/Advanced/MidiScaler/MidiCommon/ProgressionTable.hpp b/examples/Advanced/MidiScaler/MidiCommon/ProgressionTable.hpp new file mode 100644 index 000000000..be228e4da --- /dev/null +++ b/examples/Advanced/MidiScaler/MidiCommon/ProgressionTable.hpp @@ -0,0 +1,473 @@ +#pragma once + +/* SPDX-License-Identifier: GPL-3.0-or-later */ + +// Generated from chords.py of free-midi-chords; do not edit by hand. + +#include +#include +#include + +namespace mtk +{ +/** + * @brief The chord progressions of free-midi-chords by Ludovic Drolez + * (https://github.com/ldrolez/free-midi-chords, chords.py), in its order: + * major, minor, modal, then the cadences. MIT licence: + * + * Copyright (c) 2019-2026 Ludovic Drolez + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit + * persons to whom the Software is furnished to do so, subject to the + * following conditions: The above copyright notice and this permission + * notice shall be included in all copies or substantial portions of the + * Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. + * + * Minor progressions are meant in a minor key (free-midi-chords renders them + * in the relative natural minor), the others in a major key. + * The label is the progression and its moods; the tag New, which marks + * recent additions to the collection, is kept in `moods` but not shown. + */ +enum class progression_family : uint8_t +{ + major, + minor, + modal, + cadence +}; + +namespace progression_mood +{ +inline constexpr uint32_t anguished = 1u << 0; +inline constexpr uint32_t cadence = 1u << 1; +inline constexpr uint32_t dark = 1u << 2; +inline constexpr uint32_t dramatic = 1u << 3; +inline constexpr uint32_t empowered = 1u << 4; +inline constexpr uint32_t excited = 1u << 5; +inline constexpr uint32_t fearful = 1u << 6; +inline constexpr uint32_t hopeful = 1u << 7; +inline constexpr uint32_t joyful = 1u << 8; +inline constexpr uint32_t lonely = 1u << 9; +inline constexpr uint32_t mysterious = 1u << 10; +inline constexpr uint32_t new_ = 1u << 11; +inline constexpr uint32_t nostalgic = 1u << 12; +inline constexpr uint32_t peaceful = 1u << 13; +inline constexpr uint32_t playful = 1u << 14; +inline constexpr uint32_t rebellious = 1u << 15; +inline constexpr uint32_t relaxed = 1u << 16; +inline constexpr uint32_t romantic = 1u << 17; +inline constexpr uint32_t sad = 1u << 18; +inline constexpr uint32_t spiritual = 1u << 19; +inline constexpr uint32_t surprised = 1u << 20; +inline constexpr uint32_t tender = 1u << 21; +inline constexpr uint32_t triumphant = 1u << 22; +inline constexpr int count = 23; +inline constexpr std::string_view names[count]{ + "Anguished", "Cadence", "Dark", "Dramatic", "Empowered", "Excited", "Fearful", "Hopeful", "Joyful", "Lonely", "Mysterious", "New", "Nostalgic", "Peaceful", "Playful", "Rebellious", "Relaxed", "Romantic", "Sad", "Spiritual", "Surprised", "Tender", "Triumphant"}; +} + +struct progression_entry +{ + std::string_view symbols; + std::string_view label; + progression_family family; + uint32_t moods; +}; + +inline constexpr progression_entry progression_table[] = { + {"I I IM-5 IM-5 IV IV V Vsus2", "I I IM-5 IM-5 IV IV V Vsus2 (Joyful Triumphant)", + progression_family::major, progression_mood::joyful | progression_mood::triumphant}, + {"I I IV iii", "I I IV iii (Hopeful Nostalgic)", + progression_family::major, progression_mood::hopeful | progression_mood::nostalgic}, + {"I I7 Idom7 I7", "I I7 Idom7 I7 (Relaxed Playful)", + progression_family::major, progression_mood::relaxed | progression_mood::playful}, + {"I I7 Idom7 IV", "I I7 Idom7 IV (Relaxed Nostalgic)", + progression_family::major, progression_mood::relaxed | progression_mood::nostalgic}, + {"I iii IV vi", "I iii IV vi (Romantic Nostalgic)", + progression_family::major, progression_mood::romantic | progression_mood::nostalgic}, + {"I iii vi Isus4", "I iii vi Isus4 (Tender Spiritual)", + progression_family::major, progression_mood::tender | progression_mood::spiritual}, + {"I iii vi IV", "I iii vi IV (Romantic Hopeful)", + progression_family::major, progression_mood::romantic | progression_mood::hopeful}, + {"I IV ii V", "I IV ii V (Joyful Triumphant)", + progression_family::major, progression_mood::joyful | progression_mood::triumphant}, + {"I IV Isus2 IV", "I IV Isus2 IV (Peaceful Hopeful)", + progression_family::major, progression_mood::peaceful | progression_mood::hopeful}, + {"I IV V IV", "I IV V IV (Joyful Triumphant)", + progression_family::major, progression_mood::joyful | progression_mood::triumphant}, + {"I IV V", "I IV V (Joyful Excited)", + progression_family::major, progression_mood::joyful | progression_mood::excited}, + {"I IV vi V", "I IV vi V (Joyful Hopeful)", + progression_family::major, progression_mood::joyful | progression_mood::hopeful}, + {"I IV vii iii vi ii V I", "I IV vii iii vi ii V I (Romantic Triumphant)", + progression_family::major, progression_mood::romantic | progression_mood::triumphant}, + {"I V I IV", "I V I IV (Joyful Playful)", + progression_family::major, progression_mood::joyful | progression_mood::playful}, + {"I V IV vi", "I V IV vi (Romantic Hopeful)", + progression_family::major, progression_mood::romantic | progression_mood::hopeful}, + {"I V vi ii", "I V vi ii (Hopeful Romantic)", + progression_family::major, progression_mood::hopeful | progression_mood::romantic}, + {"I V vi iii IV I IV V", "I V vi iii IV I IV V (Hopeful Joyful)", + progression_family::major, progression_mood::hopeful | progression_mood::joyful}, + {"I V vi iii IV", "I V vi iii IV (Hopeful Joyful)", + progression_family::major, progression_mood::hopeful | progression_mood::joyful}, + {"I V vi IV", "I V vi IV (Hopeful Romantic)", + progression_family::major, progression_mood::hopeful | progression_mood::romantic}, + {"I V vi V", "I V vi V (Hopeful Romantic)", + progression_family::major, progression_mood::hopeful | progression_mood::romantic}, + {"I vi I IV", "I vi I IV (Tender Nostalgic)", + progression_family::major, progression_mood::tender | progression_mood::nostalgic}, + {"I vi ii IV", "I vi ii IV (Tender Nostalgic)", + progression_family::major, progression_mood::tender | progression_mood::nostalgic}, + {"I vi ii V", "I vi ii V (Nostalgic Romantic)", + progression_family::major, progression_mood::nostalgic | progression_mood::romantic}, + {"I vi IV iii", "I vi IV iii (Nostalgic Romantic)", + progression_family::major, progression_mood::nostalgic | progression_mood::romantic}, + {"I vi IV V", "I vi IV V (Romantic Hopeful)", + progression_family::major, progression_mood::romantic | progression_mood::hopeful}, + {"I7 V7 viadd9 IV7", "I7 V7 viadd9 IV7 (Playful Joyful)", + progression_family::major, progression_mood::playful | progression_mood::joyful}, + {"ii IV V", "ii IV V (Hopeful)", + progression_family::major, progression_mood::hopeful}, + {"ii IV vi V", "ii IV vi V (Romantic)", + progression_family::major, progression_mood::romantic}, + {"ii V I", "ii V I (Triumphant)", + progression_family::major, progression_mood::triumphant}, + {"ii V I IV", "ii V I IV (Hopeful Triumphant)", + progression_family::major, progression_mood::hopeful | progression_mood::triumphant}, + {"ii7 Vadd9 I7", "ii7 Vadd9 I7 (Triumphant)", + progression_family::major, progression_mood::triumphant}, + {"iii vi IV I", "iii vi IV I (Romantic Nostalgic)", + progression_family::major, progression_mood::romantic | progression_mood::nostalgic}, + {"iim7 V7 iiim7 vi7 iim7 V7", "iim7 V7 iiim7 vi7 iim7 V7 (Romantic Nostalgic)", + progression_family::major, progression_mood::romantic | progression_mood::nostalgic}, + {"Isus2 I vi7 visus4", "Isus2 I vi7 visus4 (Playful Romantic)", + progression_family::major, progression_mood::playful | progression_mood::romantic}, + {"IV I ii vi", "IV I ii vi (Nostalgic Peaceful)", + progression_family::major, progression_mood::nostalgic | progression_mood::peaceful}, + {"IV I iii IV", "IV I iii IV (Playful Joyful)", + progression_family::major, progression_mood::playful | progression_mood::joyful}, + {"IV I IV6 Iadd9", "IV I IV6 Iadd9 (Relaxed Joyful)", + progression_family::major, progression_mood::relaxed | progression_mood::joyful}, + {"IV I V vi", "IV I V vi (Joyful Romantic)", + progression_family::major, progression_mood::joyful | progression_mood::romantic}, + {"IV IV I V", "IV IV I V (Joyful Hopeful)", + progression_family::major, progression_mood::joyful | progression_mood::hopeful}, + {"IV vi I V", "IV vi I V (Hopeful Romantic)", + progression_family::major, progression_mood::hopeful | progression_mood::romantic}, + {"IV vi iii I", "IV vi iii I (Nostalgic Playful)", + progression_family::major, progression_mood::nostalgic | progression_mood::playful}, + {"IV vi IV vi", "IV vi IV vi (Nostalgic)", + progression_family::major, progression_mood::nostalgic}, + {"V I vi V", "V I vi V (Hopeful Romantic)", + progression_family::major, progression_mood::hopeful | progression_mood::romantic}, + {"V IV vi I", "V IV vi I (Hopeful Triumphant)", + progression_family::major, progression_mood::hopeful | progression_mood::triumphant}, + {"V vi IV I", "V vi IV I (Hopeful Romantic)", + progression_family::major, progression_mood::hopeful | progression_mood::romantic}, + {"vi ii V I", "vi ii V I (Hopeful Romantic)", + progression_family::major, progression_mood::hopeful | progression_mood::romantic}, + {"vi IV I V", "vi IV I V (Hopeful Romantic)", + progression_family::major, progression_mood::hopeful | progression_mood::romantic}, + {"vi IV iii V", "vi IV iii V (Hopeful Romantic)", + progression_family::major, progression_mood::hopeful | progression_mood::romantic}, + {"vi V IV V ii V I I", "vi V IV V ii V I I (Triumphant Hopeful)", + progression_family::major, progression_mood::triumphant | progression_mood::hopeful}, + {"vi V IV V", "vi V IV V (Romantic Hopeful)", + progression_family::major, progression_mood::romantic | progression_mood::hopeful}, + {"i i iv iv v7 ii5 v v7", "i i iv iv v7 ii5 v v7 (Dark Mysterious)", + progression_family::minor, progression_mood::dark | progression_mood::mysterious}, + {"i ii v i", "i ii v i (Mysterious Triumphant)", + progression_family::minor, progression_mood::mysterious | progression_mood::triumphant}, + {"i III iv VI", "i III iv VI (Nostalgic Romantic)", + progression_family::minor, progression_mood::nostalgic | progression_mood::romantic}, + {"i III VII iv", "i III VII iv (Empowered Nostalgic)", + progression_family::minor, progression_mood::empowered | progression_mood::nostalgic}, + {"i III VII VI", "i III VII VI (Nostalgic Romantic)", + progression_family::minor, progression_mood::nostalgic | progression_mood::romantic}, + {"i III7 VI7 iv", "i III7 VI7 iv (Dark Romantic)", + progression_family::minor, progression_mood::new_ | progression_mood::dark | progression_mood::romantic}, + {"i ii v III i ii v VII", "i ii v III i ii v VII (Mysterious Dramatic)", + progression_family::minor, progression_mood::mysterious | progression_mood::dramatic}, + {"i iv III VI", "i iv III VI (Nostalgic Romantic)", + progression_family::minor, progression_mood::nostalgic | progression_mood::romantic}, + {"i iv v iv", "i iv v iv (Mysterious Sad)", + progression_family::minor, progression_mood::mysterious | progression_mood::sad}, + {"i iv v", "i iv v (Sad Lonely)", + progression_family::minor, progression_mood::sad | progression_mood::lonely}, + {"i iv VI v", "i iv VI v (Sad Hopeful)", + progression_family::minor, progression_mood::sad | progression_mood::hopeful}, + {"i iv VII i", "i iv VII i (Sad Nostalgic)", + progression_family::minor, progression_mood::sad | progression_mood::nostalgic}, + {"i iv VII v i i ii V", "i iv VII v i i ii V (Mysterious Surprised)", + progression_family::minor, progression_mood::mysterious | progression_mood::surprised}, + {"i v iv VII", "i v iv VII (Sad Rebellious)", + progression_family::minor, progression_mood::sad | progression_mood::rebellious}, + {"i vdim iv VI", "i vdim iv VI (Dark Nostalgic)", + progression_family::minor, progression_mood::dark | progression_mood::nostalgic}, + {"i VI III VII i VI69 III7 VII", "i VI III VII i VI69 III7 VII (Mysterious Spiritual)", + progression_family::minor, progression_mood::mysterious | progression_mood::spiritual}, + {"i VI III VII", "i VI III VII (Nostalgic Hopeful)", + progression_family::minor, progression_mood::nostalgic | progression_mood::hopeful}, + {"i VI iv ii", "i VI iv ii (Sad Tender)", + progression_family::minor, progression_mood::sad | progression_mood::tender}, + {"i VI iv III", "i VI iv III (Sad Nostalgic)", + progression_family::minor, progression_mood::sad | progression_mood::nostalgic}, + {"i VI iv v", "i VI iv v (Sad Hopeful)", + progression_family::minor, progression_mood::sad | progression_mood::hopeful}, + {"i VI VII iv", "i VI VII iv (Mysterious Nostalgic)", + progression_family::minor, progression_mood::mysterious | progression_mood::nostalgic}, + {"i VI VII v", "i VI VII v (Mysterious Rebellious)", + progression_family::minor, progression_mood::mysterious | progression_mood::rebellious}, + {"i VI VII", "i VI VII (Triumphant Rebellious)", + progression_family::minor, progression_mood::triumphant | progression_mood::rebellious}, + {"i VII i v III VII i v i", "i VII i v III VII i v i (Mysterious Surprised)", + progression_family::minor, progression_mood::mysterious | progression_mood::surprised}, + {"i VII i v", "i VII i v (Mysterious Nostalgic)", + progression_family::minor, progression_mood::mysterious | progression_mood::nostalgic}, + {"i VII III VI", "i VII III VI (Rebellious Triumphant)", + progression_family::minor, progression_mood::rebellious | progression_mood::triumphant}, + {"i VII v VI", "i VII v VI (Mysterious Hopeful)", + progression_family::minor, progression_mood::mysterious | progression_mood::hopeful}, + {"i VII VI III iv VI VII i", "i VII VI III iv VI VII i (Mysterious Surprised)", + progression_family::minor, progression_mood::mysterious | progression_mood::surprised}, + {"i VII VI III", "i VII VI III (Nostalgic Hopeful)", + progression_family::minor, progression_mood::nostalgic | progression_mood::hopeful}, + {"i VII VI iv", "i VII VI iv (Sad Romantic)", + progression_family::minor, progression_mood::sad | progression_mood::romantic}, + {"i VII VI VII", "i VII VI VII (Rebellious Triumphant)", + progression_family::minor, progression_mood::rebellious | progression_mood::triumphant}, + {"i VII VIadd9 VII i v VI VIIsus4", "i VII VIadd9 VII i v VI VIIsus4 (Nostalgic Sad)", + progression_family::minor, progression_mood::nostalgic | progression_mood::sad}, + {"i7 VI III7 VII6 i i7 III7 iv7", "i7 VI III7 VII6 i i7 III7 iv7 (Dark Nostalgic)", + progression_family::minor, progression_mood::dark | progression_mood::nostalgic}, + {"i7 VII VI7 iv7", "i7 VII VI7 iv7 (Nostalgic Romantic)", + progression_family::minor, progression_mood::nostalgic | progression_mood::romantic}, + {"ii v i", "ii v i (Peaceful Hopeful)", + progression_family::minor, progression_mood::peaceful | progression_mood::hopeful}, + {"ii v i iv", "ii v i iv (Peaceful Nostalgic)", + progression_family::minor, progression_mood::peaceful | progression_mood::nostalgic}, + {"ii VI i iv", "ii VI i iv (Sad Hopeful)", + progression_family::minor, progression_mood::sad | progression_mood::hopeful}, + {"im7 ivsus4 v7 isus4", "im7 ivsus4 v7 isus4 (Mysterious Tender)", + progression_family::minor, progression_mood::mysterious | progression_mood::tender}, + {"iv i v VI", "iv i v VI (Nostalgic Hopeful)", + progression_family::minor, progression_mood::nostalgic | progression_mood::hopeful}, + {"iv III VII i", "iv III VII i (Nostalgic Mysterious)", + progression_family::minor, progression_mood::nostalgic | progression_mood::mysterious}, + {"iv III vsus4 VI iv i III VI", "iv III vsus4 VI iv i III VI (Nostalgic Mysterious)", + progression_family::minor, progression_mood::nostalgic | progression_mood::mysterious}, + {"iv v VI VII", "iv v VI VII (Mysterious Rebellious)", + progression_family::minor, progression_mood::mysterious | progression_mood::rebellious}, + {"iv VI v VII", "iv VI v VII (Mysterious Hopeful)", + progression_family::minor, progression_mood::mysterious | progression_mood::hopeful}, + {"iv VI VII i", "iv VI VII i (Triumphant)", + progression_family::minor, progression_mood::triumphant}, + {"v i iv VII", "v i iv VII (Dark Rebellious)", + progression_family::minor, progression_mood::dark | progression_mood::rebellious}, + {"v iv i", "v iv i (Lonely)", + progression_family::minor, progression_mood::lonely}, + {"v VI III i", "v VI III i (Hopeful Nostalgic)", + progression_family::minor, progression_mood::hopeful | progression_mood::nostalgic}, + {"v VI v i", "v VI v i (Sad)", + progression_family::minor, progression_mood::sad}, + {"VI i v III", "VI i v III (Hopeful Nostalgic)", + progression_family::minor, progression_mood::hopeful | progression_mood::nostalgic}, + {"VI i v", "VI i v (Sad)", + progression_family::minor, progression_mood::sad}, + {"VI III i v", "VI III i v (Nostalgic Dark)", + progression_family::minor, progression_mood::nostalgic | progression_mood::dark}, + {"VI iv i v", "VI iv i v (Hopeful Tender)", + progression_family::minor, progression_mood::hopeful | progression_mood::tender}, + {"VI VI i VII", "VI VI i VII (Empowered)", + progression_family::minor, progression_mood::empowered}, + {"VI VII i III", "VI VII i III (Triumphant Nostalgic)", + progression_family::minor, progression_mood::triumphant | progression_mood::nostalgic}, + {"VI VII i III VII VI iv i", "VI VII i III VII VI iv i (Nostalgic Mysterious)", + progression_family::minor, progression_mood::new_ | progression_mood::nostalgic | progression_mood::mysterious}, + {"VI VII v III", "VI VII v III (Rebellious Nostalgic)", + progression_family::minor, progression_mood::rebellious | progression_mood::nostalgic}, + {"VI7 VII i v VI7 VII i iv7", "VI7 VII i v VI7 VII i iv7 (Empowered Nostalgic)", + progression_family::minor, progression_mood::empowered | progression_mood::nostalgic}, + {"VI7 VII6 i VII6", "VI7 VII6 i VII6 (Nostalgic Hopeful)", + progression_family::minor, progression_mood::nostalgic | progression_mood::hopeful}, + {"VII iv v i", "VII iv v i (Mysterious Dark)", + progression_family::minor, progression_mood::mysterious | progression_mood::dark}, + {"VII iv VII i", "VII iv VII i (Spiritual)", + progression_family::minor, progression_mood::spiritual}, + {"bIIIM ii bIIM I", "bIIIM ii bIIM I (Mysterious Surprised)", + progression_family::modal, progression_mood::mysterious | progression_mood::surprised}, + {"bIIM bVIM biii bviim", "bIIM bVIM biii bviim (Mysterious Surprised)", + progression_family::modal, progression_mood::mysterious | progression_mood::surprised}, + {"bIIM ivm biii im", "bIIM ivm biii im (Mysterious Dark)", + progression_family::modal, progression_mood::mysterious | progression_mood::dark}, + {"bviim I/bVII bviim I/bVII", "bviim I/bVII bviim I/bVII (Dark Lonely)", + progression_family::modal, progression_mood::new_ | progression_mood::dark | progression_mood::lonely}, + {"bVIIM bIIM bIIIM im", "bVIIM bIIM bIIIM im (Mysterious Rebellious)", + progression_family::modal, progression_mood::mysterious | progression_mood::rebellious}, + {"bVIM bIIIM bVIIM IV I", "bVIM bIIIM bVIIM IV I (Triumphant Mysterious)", + progression_family::modal, progression_mood::triumphant | progression_mood::mysterious}, + {"bVIM bVIIm im bIIM", "bVIM bVIIm im bIIM (Mysterious Dark)", + progression_family::modal, progression_mood::mysterious | progression_mood::dark}, + {"bVIM vi im bVIIM", "bVIM vi im bVIIM (Hopeful Mysterious)", + progression_family::modal, progression_mood::hopeful | progression_mood::mysterious}, + {"bVIM7 ivmadd9 I", "bVIM7 ivmadd9 I (Spiritual Nostalgic)", + progression_family::modal, progression_mood::spiritual | progression_mood::nostalgic}, + {"I7 bIdom7 III7 ii7 V7", "I7 bIdom7 III7 ii7 V7 (Playful Surprised)", + progression_family::modal, progression_mood::playful | progression_mood::surprised}, + {"I bIIIM bVIIM I", "I bIIIM bVIIM I (Triumphant Rebellious)", + progression_family::modal, progression_mood::triumphant | progression_mood::rebellious}, + {"I bIIIM bVIIM IV", "I bIIIM bVIIM IV (Triumphant Mysterious)", + progression_family::modal, progression_mood::triumphant | progression_mood::mysterious}, + {"I bIIIM bVIM bVIIM", "I bIIIM bVIM bVIIM (Triumphant Hopeful)", + progression_family::modal, progression_mood::triumphant | progression_mood::hopeful}, + {"I bIIIM IV I", "I bIIIM IV I (Romantic)", + progression_family::modal, progression_mood::romantic}, + {"I bIIM I iii", "I bIIM I iii (Surprised Mysterious)", + progression_family::modal, progression_mood::surprised | progression_mood::mysterious}, + {"I bIIM7 bIIIM6 bIIM7 I im bVIIM bIIM", "I bIIM7 bIIIM6 bIIM7 I im bVIIM bIIM (Surprised Mysterious)", + progression_family::modal, progression_mood::surprised | progression_mood::mysterious}, + {"I bVIIM bVIM bIIM", "I bVIIM bVIM bIIM (Triumphant Rebellious)", + progression_family::modal, progression_mood::triumphant | progression_mood::rebellious}, + {"I bVIIM bVIM IV IVsus4 IV", "I bVIIM bVIM IV IVsus4 IV (Hopeful Nostalgic)", + progression_family::modal, progression_mood::hopeful | progression_mood::nostalgic}, + {"I bVIIM I I bVIM V", "I bVIIM I I bVIM V (Triumphant Hopeful)", + progression_family::modal, progression_mood::triumphant | progression_mood::hopeful}, + {"I bVIIM IV V", "I bVIIM IV V (Joyful Triumphant)", + progression_family::modal, progression_mood::joyful | progression_mood::triumphant}, + {"I bVIM bVIIM ivm", "I bVIM bVIIM ivm (Empowered Nostalgic)", + progression_family::modal, progression_mood::empowered | progression_mood::nostalgic}, + {"I bVIM I bIIM", "I bVIM I bIIM (Surprised Mysterious)", + progression_family::modal, progression_mood::surprised | progression_mood::mysterious}, + {"I bVIM IV bIIIM bVIIM", "I bVIM IV bIIIM bVIIM (Mysterious Nostalgic)", + progression_family::modal, progression_mood::mysterious | progression_mood::nostalgic}, + {"I I7 I9 IV ivm", "I I7 I9 IV ivm (Romantic Nostalgic)", + progression_family::modal, progression_mood::romantic | progression_mood::nostalgic}, + {"I I7 Idom7 IV ivm I", "I I7 Idom7 IV ivm I (Relaxed Nostalgic)", + progression_family::modal, progression_mood::relaxed | progression_mood::nostalgic}, + {"I I7 Idom7 IV ivm I ivm6 Vdom7", "I I7 Idom7 IV ivm I ivm6 Vdom7 (Relaxed Romantic)", + progression_family::modal, progression_mood::relaxed | progression_mood::romantic}, + {"I im bVII6 bVIM", "I im bVII6 bVIM (Nostalgic Mysterious)", + progression_family::modal, progression_mood::nostalgic | progression_mood::mysterious}, + {"I IIIM vi V", "I IIIM vi V (Joyful Hopeful)", + progression_family::modal, progression_mood::joyful | progression_mood::hopeful}, + {"I IIM iii V6", "I IIM iii V6 (Surprised Triumphant)", + progression_family::modal, progression_mood::surprised | progression_mood::triumphant}, + {"I IIM IV I", "I IIM IV I (Joyful Triumphant)", + progression_family::modal, progression_mood::joyful | progression_mood::triumphant}, + {"I IV bIIIM bVIM", "I IV bIIIM bVIM (Nostalgic Mysterious)", + progression_family::modal, progression_mood::nostalgic | progression_mood::mysterious}, + {"I IV bVIIM IV", "I IV bVIIM IV (Joyful Rebellious)", + progression_family::modal, progression_mood::joyful | progression_mood::rebellious}, + {"I IV V bVIIM", "I IV V bVIIM (Triumphant Rebellious)", + progression_family::modal, progression_mood::triumphant | progression_mood::rebellious}, + {"I ivm6 I I7", "I ivm6 I I7 (Nostalgic Tender)", + progression_family::modal, progression_mood::new_ | progression_mood::nostalgic | progression_mood::tender}, + {"I ivm bIIIM bVIIM", "I ivm bIIIM bVIIM (Mysterious Nostalgic)", + progression_family::modal, progression_mood::mysterious | progression_mood::nostalgic}, + {"I V bVIIM IV", "I V bVIIM IV (Triumphant Rebellious)", + progression_family::modal, progression_mood::triumphant | progression_mood::rebellious}, + {"I V ivm bVIM", "I V ivm bVIM (Surprised Mysterious)", + progression_family::modal, progression_mood::surprised | progression_mood::mysterious}, + {"I vi bVIM V", "I vi bVIM V (Surprised Mysterious)", + progression_family::modal, progression_mood::surprised | progression_mood::mysterious}, + {"I5 iii II5 #IVm IV5 vi V5 viim", "I5 iii II5 #IVm IV5 vi V5 viim (Excited Triumphant)", + progression_family::modal, progression_mood::excited | progression_mood::triumphant}, + {"ii bIIM I bVIIM", "ii bIIM I bVIIM (Mysterious Rebellious)", + progression_family::modal, progression_mood::mysterious | progression_mood::rebellious}, + {"ii bVIIM7 I", "ii bVIIM7 I (Hopeful Triumphant)", + progression_family::modal, progression_mood::hopeful | progression_mood::triumphant}, + {"ii IVM vm bVIIM", "ii IVM vm bVIIM (Mysterious Nostalgic)", + progression_family::modal, progression_mood::mysterious | progression_mood::nostalgic}, + {"IIIM V VIsus4 VIM I IIM", "IIIM V VIsus4 VIM I IIM (Triumphant Surprised)", + progression_family::modal, progression_mood::triumphant | progression_mood::surprised}, + {"im bIIIM bVIIM IV", "im bIIIM bVIIM IV (Romantic Nostalgic)", + progression_family::modal, progression_mood::romantic | progression_mood::nostalgic}, + {"im bIIIM bVIM V", "im bIIIM bVIM V (Mysterious Triumphant)", + progression_family::modal, progression_mood::mysterious | progression_mood::triumphant}, + {"im bIIIM IV bVIM", "im bIIIM IV bVIM (Mysterious Hopeful)", + progression_family::modal, progression_mood::mysterious | progression_mood::hopeful}, + {"im bIIIM IV V", "im bIIIM IV V (Mysterious Triumphant)", + progression_family::modal, progression_mood::mysterious | progression_mood::triumphant}, + {"im bIIIsus2 IV", "im bIIIsus2 IV (Mysterious Hopeful)", + progression_family::modal, progression_mood::mysterious | progression_mood::hopeful}, + {"im bIIM bIIIM bIIM", "im bIIM bIIIM bIIM (Mysterious Dark)", + progression_family::modal, progression_mood::mysterious | progression_mood::dark}, + {"im bIIM biim6 ivm", "im bIIM biim6 ivm (Mysterious Tender)", + progression_family::modal, progression_mood::mysterious | progression_mood::tender}, + {"im bIIM im7 bviim", "im bIIM im7 bviim (Mysterious Spiritual)", + progression_family::modal, progression_mood::mysterious | progression_mood::spiritual}, + {"im bIIM ivm IIIM bIIM ivm IIIM IIIM", "im bIIM ivm IIIM bIIM ivm IIIM IIIM (Mysterious Nostalgic)", + progression_family::modal, progression_mood::mysterious | progression_mood::nostalgic}, + {"im bIIM vm im7", "im bIIM vm im7 (Mysterious Dark)", + progression_family::modal, progression_mood::mysterious | progression_mood::dark}, + {"im bVIIM bIIM vm", "im bVIIM bIIM vm (Mysterious Rebellious)", + progression_family::modal, progression_mood::mysterious | progression_mood::rebellious}, + {"im bVIIM IV im", "im bVIIM IV im (Nostalgic Mysterious Cadence)", + progression_family::modal, progression_mood::nostalgic | progression_mood::mysterious | progression_mood::cadence}, + {"im bviim bVIM bIIM", "im bviim bVIM bIIM (Mysterious Hopeful)", + progression_family::modal, progression_mood::mysterious | progression_mood::hopeful}, + {"im bVIM im IV", "im bVIM im IV (Nostalgic Hopeful)", + progression_family::modal, progression_mood::nostalgic | progression_mood::hopeful}, + {"im bVIM ivm V", "im bVIM ivm V (Triumphant)", + progression_family::modal, progression_mood::triumphant}, + {"im ii vm IV", "im ii vm IV (Nostalgic Hopeful)", + progression_family::modal, progression_mood::nostalgic | progression_mood::hopeful}, + {"im ivm9 bIIM im vm ivm7 bIIM im7", "im ivm9 bIIM im vm ivm7 bIIM im7 (Mysterious Tender)", + progression_family::modal, progression_mood::mysterious | progression_mood::tender}, + {"im ivm9 bIIM im", "im ivm9 bIIM im (Mysterious Nostalgic)", + progression_family::modal, progression_mood::mysterious | progression_mood::nostalgic}, + {"im V bVIIM IV bVIM bIIIM ivm V", "im V bVIIM IV bVIM bIIIM ivm V (Mysterious Rebellious)", + progression_family::modal, progression_mood::mysterious | progression_mood::rebellious}, + {"im VIM bi V", "im VIM bi V (Mysterious Triumphant)", + progression_family::modal, progression_mood::mysterious | progression_mood::triumphant}, + {"im VIM IIIM bIIM", "im VIM IIIM bIIM (Nostalgic Surprised)", + progression_family::modal, progression_mood::nostalgic | progression_mood::surprised}, + {"im vm isus4 I", "im vm isus4 I (Hopeful Spiritual)", + progression_family::modal, progression_mood::hopeful | progression_mood::spiritual}, + {"im vm bVIM bIIM", "im vm bVIM bIIM (Mysterious Hopeful)", + progression_family::modal, progression_mood::mysterious | progression_mood::hopeful}, + {"im vm bVIIM IV", "im vm bVIIM IV (Hopeful Nostalgic)", + progression_family::modal, progression_mood::hopeful | progression_mood::nostalgic}, + {"im vm ivm bIIM7", "im vm ivm bIIM7 (Mysterious Dark)", + progression_family::modal, progression_mood::mysterious | progression_mood::dark}, + {"im viim/i im bii/i", "im viim/i im bii/i (Mysterious Fearful)", + progression_family::modal, progression_mood::new_ | progression_mood::mysterious | progression_mood::fearful}, + {"iii im iii im7", "iii im iii im7 (Anguished Mysterious)", + progression_family::modal, progression_mood::anguished | progression_mood::mysterious}, + {"IV V ii im bIIIM IV", "IV V ii im bIIIM IV (Hopeful Nostalgic)", + progression_family::modal, progression_mood::hopeful | progression_mood::nostalgic}, + {"ivm bIIIM iim7 V", "ivm bIIIM iim7 V (Mysterious Tender)", + progression_family::modal, progression_mood::mysterious | progression_mood::tender}, + {"ivm im bviim bVIM", "ivm im bviim bVIM (Dark)", + progression_family::modal, progression_mood::dark}, + {"vdim vdim ivm bIIIM", "vdim vdim ivm bIIIM (Fearful Mysterious)", + progression_family::modal, progression_mood::fearful | progression_mood::mysterious}, + {"vi bVIIM im bVIM", "vi bVIIM im bVIM (Hopeful Peaceful)", + progression_family::modal, progression_mood::new_ | progression_mood::hopeful | progression_mood::peaceful}, + {"vi bVIM bVIIM I", "vi bVIM bVIIM I (Hopeful Triumphant)", + progression_family::modal, progression_mood::hopeful | progression_mood::triumphant}, + {"vi IV I IIM", "vi IV I IIM (Hopeful Peaceful)", + progression_family::modal, progression_mood::hopeful | progression_mood::peaceful}, + {"vi viim V vi #IVdim V", "vi viim V vi #IVdim V (Nostalgic Dark)", + progression_family::modal, progression_mood::nostalgic | progression_mood::dark}, + {"VIM bVIM im bVIIM", "VIM bVIM im bVIIM (Surprised Rebellious)", + progression_family::modal, progression_mood::surprised | progression_mood::rebellious}, + {"bIIIM V7 I", "bIIIM V7 I (Cadence)", + progression_family::cadence, progression_mood::cadence}, + {"bVIIM V7 I", "bVIIM V7 I (Cadence)", + progression_family::cadence, progression_mood::cadence}, + {"ivm bIIIM bIIM I", "ivm bIIIM bIIM I (Cadence)", + progression_family::cadence, progression_mood::cadence}, + {"ivm IIIM bIIM I", "ivm IIIM bIIM I (Cadence)", + progression_family::cadence, progression_mood::cadence}, + {"ivm bIIIM bVIM I", "ivm bIIIM bVIM I (Cadence)", + progression_family::cadence, progression_mood::cadence}, +}; + +inline constexpr int progression_count = int(std::size(progression_table)); +static_assert(progression_count == 194); +} diff --git a/examples/Advanced/MidiScaler/MidiCommon/Progressions.hpp b/examples/Advanced/MidiScaler/MidiCommon/Progressions.hpp new file mode 100644 index 000000000..dd93b6a69 --- /dev/null +++ b/examples/Advanced/MidiScaler/MidiCommon/Progressions.hpp @@ -0,0 +1,874 @@ +#pragma once + +/* SPDX-License-Identifier: GPL-3.0-or-later */ + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace mtk +{ +/** + * @brief Roman-numeral chord symbols, in the notation of free-midi-chords. + * + * free-midi-chords (https://github.com/ldrolez/free-midi-chords, MIT) writes + * its progressions as roman numerals and renders them with chords2midi on top + * of a fork of python-mingus (https://github.com/ldrolez/python-mingus). The + * meaning of a symbol is the one mingus gives it; every rule below names the + * function it comes from. + * + * Grammar of a symbol: + * + * [b|#]? NUMERAL SUFFIX? ('/' [b|#]? NUMERAL SUFFIX?)? + * + * - NUMERAL is I to VII in any case (progressions.parse_string upper-cases + * every I and V it reads): `ii`, `II` and `Ii` are the same degree. The case + * is only a reminder of the chord's quality and is kept for display. + * - No suffix: the diatonic triad of that degree in the key + * (progressions.to_chords calls chords.I(key) ... chords.VII(key), i.e. + * chords.triads(key)[degree], thirds stacked on the key's own notes). + * `iii` in C major is E minor, `III` too. + * - Suffix `7`: the diatonic seventh chord (to_chords calls chords.I7(key) + * ... chords.VII7(key), chords.sevenths(key)). `V7` in C major is G7, `I7` + * is Cmaj7, `ii7` is Dm7, `vii7` is Bm7b5. mingus' docstring: "I7 will get + * you a major seventh chord. If you specifically want a dominant seventh, + * use Idom7." + * - Any other suffix: the chord of chords.chord_shorthand[suffix], built on + * the root of the diatonic triad (to_chords: `r = chords.I(key); + * r = chord_shorthand[suffix](r[0])`). The suffix decides the quality, the + * case of the numeral does not: `IVm` and `ivm` are both F minor in C. + * `M` and `m` force a major or minor triad, `dim` a diminished triad, `M-5` + * a major triad with a flat fifth (major_diminished_triad), `5` root and + * fifth, `6`/`M6` a major sixth chord, `m6`, `69` (sixth_ninth), `9` a + * dominant ninth, `M9`, `m9`, `add9` (major triad + 9), `madd9`, + * `sus2`, `sus4`, `M7`, `m7`, `dom7` (dominant_seventh), and the other + * entries of chord_shorthand listed in mingus_suffixes below. + * - A flat or a sharp before the numeral lowers or raises every note of the + * chord by a semitone, after it is built (to_chords: notes.diminish / + * notes.augment on each note). The quality is the natural degree's: in C, + * `bIII` is the E minor triad lowered, i.e. Eb minor, which is why + * free-midi-chords writes `bIIIM` for Eb major. `bi` is B major: I lowered. + * - `/` and a second symbol: a slash chord. chords2midi (c2m.py) splits the + * symbol at the first `/`, and the bass is the root of the second symbol's + * chord (`bass_root = to_chords(bass_part, key)[0][0]`); its suffix is + * checked but plays no part. `I/bVII` in C is C major over Bb. + * - `X` (or `x`) is a rest. In a progression, symbols are separated by one + * space and every further space adds a rest (gen.py turns two spaces into + * ` X `). Anything from ` =` on (the moods in chords.py) is ignored. + * + * Departures from mingus, all on input it would reject or misread: a single + * accidental only (mingus takes any number); an unknown suffix is an error + * (mingus raises KeyError, chords2midi then plays a rest); the suffixes that + * contain a `/` (`m/M7`, `6/7`, `6/9`) are not accepted since chords2midi + * splits there too (`mM7`, `67`, `69` are); and after mingus' own suffixes, + * the chord type names of Theory.hpp (`maj7`, `m7-5`, `7sus4`, ...) and `2` + * (free-midi-chords: same as add9) are accepted as well. + * + * Keys: free-midi-chords renders its major and modal progressions in a major + * key and its minor ones in the relative natural minor (gen.py passes + * `root_min.lower()`, and mingus' keys.get_notes of a lower-case key gives the + * natural minor). Here the key is any root and any mode of Theory.hpp; the + * diatonic chords follow that mode. + */ + +//! The chord types of mingus' chords.chord_shorthand, by their suffix, as +//! semitones from the root (compound intervals for the 9th, 11th and 13th). +//! Where mingus' name differs from what the intervals suggest, mingus wins: +//! `M7+5` and `m7+` are augmented_minor_seventh (0 4 8 10), `7+` and `M7+` +//! augmented_major_seventh (0 4 8 11), `11` is eleventh (root, 5, b7, 11). +inline constexpr chord_type mingus_suffixes[] = { + {"m", {0, 3, 7}, 3}, // minor_triad + {"M", {0, 4, 7}, 3}, // major_triad + {"dim", {0, 3, 6}, 3}, // diminished_triad + {"aug", {0, 4, 8}, 3}, // augmented_triad + {"+", {0, 4, 8}, 3}, // augmented_triad + {"M-5", {0, 4, 6}, 3}, // major_diminished_triad + {"7#5", {0, 4, 8, 10}, 4}, // augmented_minor_seventh + {"M7+5", {0, 4, 8, 10}, 4}, // augmented_minor_seventh + {"M7+", {0, 4, 8, 11}, 4}, // augmented_major_seventh + {"m7+", {0, 4, 8, 10}, 4}, // augmented_minor_seventh + {"7+", {0, 4, 8, 11}, 4}, // augmented_major_seventh + {"sus47", {0, 5, 7, 10}, 4}, // suspended_seventh + {"sus4", {0, 5, 7}, 3}, // suspended_fourth_triad + {"sus2", {0, 2, 7}, 3}, // suspended_second_triad + {"sus", {0, 5, 7}, 3}, // suspended_triad + {"11", {0, 7, 10, 17}, 4}, // eleventh + {"sus4b9", {0, 5, 7, 13}, 4}, // suspended_fourth_ninth + {"susb9", {0, 5, 7, 13}, 4}, // suspended_fourth_ninth + {"m7", {0, 3, 7, 10}, 4}, // minor_seventh + {"M7", {0, 4, 7, 11}, 4}, // major_seventh + {"dom7", {0, 4, 7, 10}, 4}, // dominant_seventh + {"m7b5", {0, 3, 6, 10}, 4}, // minor_seventh_flat_five + {"dim7", {0, 3, 6, 9}, 4}, // diminished_seventh + {"mM7", {0, 3, 7, 11}, 4}, // minor_major_seventh + {"m6", {0, 3, 7, 9}, 4}, // minor_sixth + {"M6", {0, 4, 7, 9}, 4}, // major_sixth + {"6", {0, 4, 7, 9}, 4}, // major_sixth + {"67", {0, 4, 7, 9, 10}, 5}, // dominant_sixth + {"69", {0, 4, 7, 9, 14}, 5}, // sixth_ninth + {"9", {0, 4, 7, 10, 14}, 5}, // dominant_ninth + {"7b9", {0, 4, 7, 10, 13}, 5}, // dominant_flat_ninth + {"7#9", {0, 4, 7, 10, 15}, 5}, // dominant_sharp_ninth + {"M9", {0, 4, 7, 11, 14}, 5}, // major_ninth + {"m9", {0, 3, 7, 10, 14}, 5}, // minor_ninth + {"add9", {0, 4, 7, 14}, 4}, // major_add_ninth + {"madd9", {0, 3, 7, 14}, 4}, // minor_add_ninth + {"7#11", {0, 4, 7, 10, 18}, 5}, // lydian_dominant_seventh + {"m11", {0, 3, 7, 10, 17}, 5}, // minor_eleventh + {"M13", {0, 4, 7, 11, 14, 21}, 6}, // major_thirteenth + {"m13", {0, 3, 7, 10, 14, 21}, 6}, // minor_thirteenth + {"13", {0, 4, 7, 10, 14, 21}, 6}, // dominant_thirteenth + {"7b5", {0, 4, 6, 10}, 4}, // dominant_flat_five + {"hendrix", {0, 4, 7, 10, 15}, 5}, // hendrix_chord + {"7b12", {0, 4, 7, 10, 15}, 5}, // hendrix_chord + {"5", {0, 7}, 2}, // power chord +}; + +//! The chord type a suffix names: mingus' first, then Theory.hpp's, then +//! free-midi-chords' `2`. nullptr if none. +inline constexpr const chord_type* find_suffix(std::string_view s) noexcept +{ + for(const auto& c : mingus_suffixes) + if(c.name == s) + return &c; + if(auto t = find_chord_type(s)) + return t; + if(s == "2") + return find_chord_type("add9"); + return nullptr; +} + +//! One symbol of a progression. Positions refer to the parsed text. +struct chord_symbol +{ + bool rest{}; + bool lower{}; //!< numeral written in lower case + bool seventh{}; //!< suffix `7`: the diatonic seventh chord + bool slash{}; + int8_t degree{}; //!< 0 - 6 + int8_t accidental{}; //!< -1, 0, +1 + int8_t bass_degree{}; + int8_t bass_accidental{}; + const chord_type* type{}; //!< nullptr: diatonic triad or seventh + uint16_t position{}; + uint16_t length{}; + + std::string_view text(std::string_view source) const noexcept + { + if(position >= source.size()) + return {}; + return source.substr(position, length); + } +}; + +enum class parse_status : uint8_t +{ + ok, + empty, + too_many, + double_accidental, + missing_numeral, + bad_numeral, + unknown_suffix, + bad_bass +}; + +inline constexpr std::string_view parse_status_text(parse_status s) noexcept +{ + switch(s) + { + case parse_status::ok: + return "OK"; + case parse_status::empty: + return "No chord"; + case parse_status::too_many: + return "Too many chords"; + case parse_status::double_accidental: + return "One flat or sharp at most"; + case parse_status::missing_numeral: + return "Expected a roman numeral"; + case parse_status::bad_numeral: + return "Not a degree from I to VII"; + case parse_status::unknown_suffix: + return "Unknown chord type"; + case parse_status::bad_bass: + return "Bad bass after /"; + } + return {}; +} + +//! Status, and the offending token's position and length in the text. +struct parse_error +{ + parse_status status{parse_status::ok}; + uint16_t position{}; + uint16_t length{}; + + explicit operator bool() const noexcept { return status != parse_status::ok; } +}; + +struct parsed_progression +{ + static constexpr int max_chords = 64; + std::array chords{}; + int count{}; + + std::span symbols() const noexcept + { + return {chords.data(), std::size_t(count)}; + } +}; + +namespace detail +{ +inline constexpr int numeral_index(std::string_view upper) noexcept +{ + constexpr std::string_view numerals[7]{"I", "II", "III", "IV", "V", "VI", "VII"}; + for(int i = 0; i < 7; i++) + if(numerals[i] == upper) + return i; + return -1; +} + +//! [b|#]? NUMERAL: reads it from s at i. Returns a status; i is moved past it. +inline constexpr parse_status +read_degree(std::string_view s, std::size_t& i, int8_t& degree, int8_t& acc, bool& lower) +{ + acc = 0; + if(i < s.size() && (s[i] == 'b' || s[i] == '#')) + { + acc = s[i] == 'b' ? -1 : 1; + i++; + if(i < s.size() && (s[i] == 'b' || s[i] == '#')) + return parse_status::double_accidental; + } + char buf[8]{}; + std::size_t n = 0; + const std::size_t start = i; + while(i < s.size()) + { + const char c = s[i]; + const char u = (c >= 'a' && c <= 'z') ? char(c - 'a' + 'A') : c; + if(u != 'I' && u != 'V') + break; + if(n < sizeof(buf)) + buf[n] = u; + n++; + i++; + } + if(n == 0) + return parse_status::missing_numeral; + if(n > 3) + return parse_status::bad_numeral; + const int d = numeral_index(std::string_view{buf, n}); + if(d < 0) + return parse_status::bad_numeral; + degree = int8_t(d); + lower = s[start] == 'i' || s[start] == 'v'; + return parse_status::ok; +} +} + +//! Parses one symbol (no spaces). The error position is relative to `s`. +inline constexpr parse_error parse_symbol(std::string_view s, chord_symbol& out) noexcept +{ + out = {}; + const auto fail = [&](parse_status st) { + return parse_error{st, 0, uint16_t(std::min(s.size(), 0xFFFF))}; + }; + if(s.empty()) + return fail(parse_status::missing_numeral); + if(s == "X" || s == "x") + { + out.rest = true; + return {}; + } + + const std::size_t slash = s.find('/'); + const std::string_view head = s.substr(0, slash); + + std::size_t i = 0; + if(auto st = detail::read_degree(head, i, out.degree, out.accidental, out.lower); + st != parse_status::ok) + return fail(st); + + const std::string_view suffix = head.substr(i); + if(suffix == "7") + out.seventh = true; + else if(!suffix.empty()) + { + out.type = find_suffix(suffix); + if(!out.type) + return fail(parse_status::unknown_suffix); + } + + if(slash != std::string_view::npos) + { + const std::string_view tail = s.substr(slash + 1); + std::size_t j = 0; + bool lower{}; + if(detail::read_degree(tail, j, out.bass_degree, out.bass_accidental, lower) + != parse_status::ok) + return fail(parse_status::bad_bass); + const std::string_view bsuf = tail.substr(j); + if(!bsuf.empty() && bsuf != "7" && !find_suffix(bsuf)) + return fail(parse_status::bad_bass); + out.slash = true; + } + return {}; +} + +/** + * @brief Parses a whole progression, e.g. "I V vi IV" or "ii7 V7 I7 I". + * + * Never allocates. On error, `out` holds the symbols before the bad one and + * the error gives the bad token's position and length in `text`. + */ +inline constexpr parse_error +parse_progression(std::string_view text, parsed_progression& out) noexcept +{ + out.count = 0; + if(auto eq = text.find(" ="); eq != std::string_view::npos) + text = text.substr(0, eq); + if(text.size() > 0xFFFF) + text = text.substr(0, 0xFFFF); + + std::size_t i = 0; + while(i < text.size() && text[i] == ' ') + i++; + + const auto push = [&](const chord_symbol& c) { + if(out.count >= parsed_progression::max_chords) + return false; + out.chords[out.count++] = c; + return true; + }; + + while(i < text.size()) + { + const std::size_t start = i; + while(i < text.size() && text[i] != ' ') + i++; + const std::string_view tok = text.substr(start, i - start); + + chord_symbol c; + if(auto err = parse_symbol(tok, c)) + { + err.position = uint16_t(start + err.position); + return err; + } + c.position = uint16_t(start); + c.length = uint16_t(tok.size()); + if(!push(c)) + return {parse_status::too_many, uint16_t(start), uint16_t(tok.size())}; + + std::size_t spaces = 0; + while(i < text.size() && text[i] == ' ') + { + i++; + spaces++; + } + if(i < text.size()) + { + for(std::size_t k = 1; k < spaces; k++) + { + chord_symbol r; + r.rest = true; + r.position = uint16_t(i - spaces + k); + r.length = 0; + if(!push(r)) + return {parse_status::too_many, r.position, 1}; + } + } + } + if(out.count == 0) + return {parse_status::empty, 0, 0}; + return {}; +} + +/** + * @brief Writes "Unknown chord type: IVxx (at 5)" into `buf`, truncated to its + * size. Returns the length written. + */ +inline std::size_t format_parse_error( + const parse_error& e, std::string_view text, std::span buf) noexcept +{ + std::size_t n = 0; + const auto put = [&](std::string_view s) { + for(char c : s) + if(n < buf.size()) + buf[n++] = c; + }; + put(parse_status_text(e.status)); + if(e.status == parse_status::ok || e.status == parse_status::empty) + return n; + if(e.position < text.size()) + { + put(": "); + put(text.substr(e.position, std::max(e.length, 1))); + } + put(" (at "); + char num[8]{}; + int k = 0; + int v = e.position; + do + { + num[k++] = char('0' + v % 10); + v /= 10; + } while(v > 0 && k < 7); + while(k > 0) + put(std::string_view{&num[--k], 1}); + put(")"); + return n; +} + +// ---------------------------------------------------------------- realize + +//! A chord in a key: root pitch class and intervals from the root. +struct realized_chord +{ + bool rest{true}; + int8_t root{}; //!< pitch class, 0 - 11 + int8_t bass{-1}; //!< pitch class of a slash chord's bass, or -1 + int8_t count{}; + std::array intervals{}; //!< ascending, the root first + + std::span notes() const noexcept + { + return {intervals.data(), std::size_t(count)}; + } + + bool has_pitch_class(int pc) const noexcept + { + for(int i = 0; i < count; i++) + if((root + intervals[i]) % 12 == ((pc % 12) + 12) % 12) + return true; + return false; + } +}; + +inline constexpr int pitch_class(int p) noexcept +{ + return ((p % 12) + 12) % 12; +} + +//! The diatonic chord of `degree` in a mode, `size` notes stacked in thirds. +inline constexpr realized_chord diatonic_chord(mode m, int key_root, int degree, int size) noexcept +{ + realized_chord r; + r.rest = false; + const int base = degree_to_semitones(m, degree); + r.root = int8_t(pitch_class(key_root + base)); + r.count = int8_t(std::clamp(size, 1, 6)); + for(int k = 0; k < r.count; k++) + r.intervals[k] = int8_t(degree_to_semitones(m, degree + 2 * k) - base); + return r; +} + +/** + * @brief The notes of a symbol in a key, following mingus' to_chords (see the + * rules at the top of this file). + */ +inline constexpr realized_chord +realize(const chord_symbol& s, int key_root, mode m) noexcept +{ + if(s.rest) + return {}; + realized_chord r; + if(s.type) + { + r.rest = false; + r.root = int8_t(pitch_class(key_root + degree_to_semitones(m, s.degree))); + r.count = s.type->count; + for(int k = 0; k < r.count; k++) + r.intervals[k] = s.type->intervals[k]; + } + else + { + r = diatonic_chord(m, key_root, s.degree, s.seventh ? 4 : 3); + } + r.root = int8_t(pitch_class(r.root + s.accidental)); + if(s.slash) + { + const int b + = pitch_class(key_root + degree_to_semitones(m, s.bass_degree) + s.bass_accidental); + if(b != r.root) + r.bass = int8_t(b); + } + return r; +} + +// ------------------------------------------------------------------ names + +//! Whether a key's chords read better with flats: a key signature with flats +//! (or C, whose chromatic chords are mostly flat degrees). +inline constexpr bool key_prefers_flats(int key_root, mode m) noexcept +{ + constexpr int tonic_degree[9]{0, 1, 2, 3, 4, 5, 6, 5, 5}; + const int parent = pitch_class(key_root - mode_steps[0][tonic_degree[int(m)]]); + switch(parent) + { + case 7: + case 2: + case 9: + case 4: + case 11: + case 6: + return false; + default: + return true; + } +} + +inline constexpr std::string_view note_name(int pc, bool flats) noexcept +{ + constexpr std::string_view sharp[12]{"C", "C#", "D", "D#", "E", "F", + "F#", "G", "G#", "A", "A#", "B"}; + constexpr std::string_view flat[12]{"C", "Db", "D", "Eb", "E", "F", + "Gb", "G", "Ab", "A", "Bb", "B"}; + return flats ? flat[pitch_class(pc)] : sharp[pitch_class(pc)]; +} + +//! A name for a set of intervals: Theory.hpp's chord type names first (the +//! major triad shown as nothing), then mingus' suffixes; "?" if none fits. +inline constexpr std::string_view chord_suffix_name(std::span iv) noexcept +{ + const auto same = [&](const chord_type& c) { + if(std::size_t(c.count) != iv.size()) + return false; + for(std::size_t i = 0; i < iv.size(); i++) + if(c.intervals[i] != iv[i]) + return false; + return true; + }; + for(const auto& c : chord_types) + if(same(c)) + return c.name == "M" ? std::string_view{} : c.name; + for(const auto& c : mingus_suffixes) + if(same(c)) + return c.name; + return "?"; +} + +//! "Am7", "C/Bb", "Ebmaj7" into `buf`. Returns the length written, 0 for a rest. +inline std::size_t +format_chord_name(const realized_chord& c, bool flats, std::span buf) noexcept +{ + std::size_t n = 0; + const auto put = [&](std::string_view s) { + for(char ch : s) + if(n < buf.size()) + buf[n++] = ch; + }; + if(c.rest) + return 0; + put(note_name(c.root, flats)); + put(chord_suffix_name(c.notes())); + if(c.bass >= 0) + { + put("/"); + put(note_name(c.bass, flats)); + } + return n; +} + +// ---------------------------------------------------------------- voicing + +enum class inversion_mode : uint8_t +{ + root, //!< root position (a slash chord's bass lowest when it is a chord tone) + closest //!< the inversion closest to the previous chord +}; + +struct voicing_options +{ + int center{60}; //!< the register: the chord's mean pitch stays near it + inversion_mode inversion{inversion_mode::closest}; + bool bass{}; //!< add the bass (root, or slash bass) an octave below + int max_notes{6}; //!< chord tones kept, the bass not counted +}; + +//! Pitches of a voiced chord, ascending. +struct voicing +{ + static constexpr int max_notes = 8; + std::array notes{}; + int8_t count{}; + bool has_bass{}; //!< notes[0] is the added bass note + + std::span all() const noexcept + { + return {notes.data(), std::size_t(count)}; + } + std::span upper() const noexcept + { + const int b = has_bass && count > 0 ? 1 : 0; + return {notes.data() + b, std::size_t(count - b)}; + } +}; + +namespace detail +{ +//! Total movement between two chords: each note to its nearest note of the +//! other chord, both ways, so that chords of different sizes compare. +inline int voice_movement(std::span a, std::span b) noexcept +{ + int cost = 0; + for(int x : a) + { + int best = 128; + for(int y : b) + best = std::min(best, std::abs(x - y)); + cost += best; + } + for(int y : b) + { + int best = 128; + for(int x : a) + best = std::min(best, std::abs(x - y)); + cost += best; + } + return cost; +} + +inline void insert_sorted(voicing& v, int pitch) noexcept +{ + if(pitch < 0 || pitch > 127 || v.count >= voicing::max_notes) + return; + int i = v.count; + while(i > 0 && v.notes[i - 1] > pitch) + { + v.notes[i] = v.notes[i - 1]; + i--; + } + v.notes[i] = uint8_t(pitch); + v.count++; +} +} + +/** + * @brief Voices a chord near `o.center`, as close as possible to `previous`. + * + * Tones: with fewer than all allowed, the root and the third (second + * interval) stay, then the top tones (seventh, extensions), the fifth goes + * first. Inversion k puts the k-th tone lowest and the ones under it an octave + * up, in close position (ninths and above stay compound in root position). + * + * Root mode, and the first chord in closest mode: root position, at the + * octave whose mean pitch is nearest the center. + * Closest mode: among the inversions whose mean is within 6 semitones of the + * center (so the register never drifts), the one with the least total + * movement from `previous`'s chord tones; ties go to the one nearer the + * center. + * A slash chord's bass is the lowest note: the inversion that starts on it if + * it is a chord tone, else an added note under the chord. With `o.bass`, the + * bass (the slash bass, else the root) is added at most an octave below the + * center instead, and is notes[0]. + */ +inline voicing +voice(const realized_chord& c, const voicing& previous, const voicing_options& o) noexcept +{ + voicing res; + if(c.rest || c.count <= 0) + return res; + + const int n = c.count; + const int keep = std::clamp(o.max_notes, 1, n); + std::array tones{}; + { + int order[6]{}; + int k = 0; + order[k++] = 0; + if(n > 1) + order[k++] = 1; + for(int i = n - 1; i >= 2; i--) + order[k++] = i; + for(int i = 0; i < keep; i++) + tones[i] = c.intervals[order[i]]; + std::sort(tones.begin(), tones.begin() + keep); + } + + const int slash_pc = c.bass; + int lowest_pc = -1; + bool slash_below = false; + if(slash_pc >= 0 && !o.bass) + { + bool in_chord = false; + for(int i = 0; i < keep; i++) + if(pitch_class(c.root + tones[i]) == slash_pc) + in_chord = true; + if(in_chord) + lowest_pc = slash_pc; + else + slash_below = true; + } + + const auto prev = previous.upper(); + const bool by_movement = o.inversion == inversion_mode::closest && !prev.empty(); + + struct candidate + { + std::array p{}; + double dist{1e9}; + int cost{1 << 30}; + bool valid{}; + }; + candidate best; + candidate best_any; + + for(int inv = 0; inv < keep; inv++) + { + if(!by_movement && lowest_pc < 0 && inv != 0) + continue; + std::array rel{}; + for(int i = 0; i < keep; i++) + rel[i] = i < inv ? tones[i] + 12 : tones[i]; + std::sort(rel.begin(), rel.begin() + keep); + if(lowest_pc >= 0 && pitch_class(c.root + rel[0]) != lowest_pc) + continue; + + for(int oct = -1; oct <= 10; oct++) + { + const int base = c.root + 12 * oct; + if(base + rel[0] < 0 || base + rel[keep - 1] > 127) + continue; + candidate cand; + double sum = 0.; + for(int i = 0; i < keep; i++) + { + cand.p[i] = uint8_t(base + rel[i]); + sum += cand.p[i]; + } + cand.dist = std::abs(sum / keep - o.center); + cand.valid = true; + if(by_movement) + cand.cost = detail::voice_movement({cand.p.data(), std::size_t(keep)}, prev); + else + cand.cost = 0; + + const auto better = [](const candidate& a, const candidate& b) { + if(!b.valid) + return true; + if(a.cost != b.cost) + return a.cost < b.cost; + return a.dist < b.dist - 1e-9; + }; + if(cand.dist <= 6. + 1e-9 && better(cand, best)) + best = cand; + // Fallback when no candidate sits in the window: nearest the center. + if(!best_any.valid || cand.dist < best_any.dist - 1e-9) + best_any = cand; + } + } + if(!best.valid) + best = best_any; + + if(best.valid) + { + for(int i = 0; i < keep; i++) + res.notes[i] = best.p[i]; + res.count = int8_t(keep); + } + else + { + // Center at the edge of the MIDI range: root position, what fits. + const int base = c.root + 12 * ((o.center - c.root) / 12); + for(int i = 0; i < keep; i++) + detail::insert_sorted(res, base + tones[i]); + } + if(res.count == 0) + return res; + + if(o.bass) + { + const int pc = slash_pc >= 0 ? slash_pc : c.root; + int b = o.center - 12; + while(b >= 0 && pitch_class(b) != pc) + b--; + while(b >= 0 && b >= res.notes[0]) + b -= 12; + if(b >= 0 && res.count < voicing::max_notes) + { + detail::insert_sorted(res, b); + res.has_bass = true; + } + } + else if(slash_below) + { + int b = res.notes[0] - 1; + while(b >= 0 && pitch_class(b) != slash_pc) + b--; + if(b >= 0) + detail::insert_sorted(res, b); + } + return res; +} + +// ------------------------------------------------------------ conforming + +//! The pitch nearest `pitch` whose pitch class `ok(pc)` accepts, ties going +//! down, within 0 - 127; `pitch` itself if none within an octave. +template +inline int nearest_pitch(int pitch, F&& ok) noexcept +{ + for(int d = 0; d <= 12; d++) + { + if(pitch - d >= 0 && ok(pitch_class(pitch - d))) + return pitch - d; + if(d > 0 && pitch + d <= 127 && ok(pitch_class(pitch + d))) + return pitch + d; + } + return pitch; +} + +//! Nearest tone of the chord, ties going down. +inline int nearest_chord_tone(int pitch, const realized_chord& c) noexcept +{ + if(c.rest) + return pitch; + return nearest_pitch(pitch, [&](int pc) { return c.has_pitch_class(pc); }); +} + +//! Nearest tone of the scale, ties going down. +inline int nearest_scale_tone(int pitch, int key_root, mode m) noexcept +{ + return nearest_pitch(pitch, [&](int pc) { + for(int s : mode_steps[int(m)]) + if(pitch_class(key_root + s) == pc) + return true; + return false; + }); +} + +/** + * @brief Tone `index` of the chord counted over octaves: 0 is the root, 1 the + * next tone up, and past the last tone the root an octave higher. Negative + * indices go down. `root_pitch` is the pitch of tone 0. + */ +inline int chord_tone(const realized_chord& c, int root_pitch, int index) noexcept +{ + if(c.rest || c.count <= 0) + return root_pitch; + std::array pcs{}; + int n = 0; + for(int i = 0; i < c.count; i++) + { + const int r = c.intervals[i] % 12; + bool dup = false; + for(int k = 0; k < n; k++) + dup |= pcs[k] == r; + if(!dup) + pcs[n++] = r; + } + std::sort(pcs.begin(), pcs.begin() + n); + const int oct = index >= 0 ? index / n : -((-index + n - 1) / n); + const int k = index - oct * n; + return root_pitch + 12 * oct + pcs[k]; +} +} diff --git a/examples/Advanced/MidiScaler/MidiCommon/Rhythm.hpp b/examples/Advanced/MidiScaler/MidiCommon/Rhythm.hpp new file mode 100644 index 000000000..32019f8ad --- /dev/null +++ b/examples/Advanced/MidiScaler/MidiCommon/Rhythm.hpp @@ -0,0 +1,34 @@ +#pragma once + +/* SPDX-License-Identifier: GPL-3.0-or-later */ + +namespace mtk +{ +//! Whether step `i` of `steps` is a hit when `pulses` of them are spread as +//! evenly as possible, the rhythm turned `rotation` steps later. +inline bool euclid_hit(int i, int steps, int pulses, int rotation) noexcept +{ + if(steps <= 0 || pulses <= 0) + return false; + if(pulses >= steps) + return true; + const int j = (((i - rotation) % steps) + steps) % steps; + return (j * pulses) % steps < pulses; +} + +/** + * @brief Delay of a step under swing, as a fraction of the step length. + * + * Steps go by pairs and the second of each pair is late by `amount * 2 / 3` + * of a step: 0 is straight, 0.5 puts the off-beat on the triplet (2/3 of the + * pair), 1 at 5/6 of the pair. + */ +inline double swing_delay(long long step, double amount) noexcept +{ + if((step & 1) == 0 || amount <= 0.) + return 0.; + if(amount > 1.) + amount = 1.; + return amount * 2. / 3.; +} +} diff --git a/examples/Advanced/MidiScaler/MidiCommon/Theory.hpp b/examples/Advanced/MidiScaler/MidiCommon/Theory.hpp new file mode 100644 index 000000000..c604f08df --- /dev/null +++ b/examples/Advanced/MidiScaler/MidiCommon/Theory.hpp @@ -0,0 +1,152 @@ +#pragma once + +/* SPDX-License-Identifier: GPL-3.0-or-later */ + +#include +#include +#include +#include + +namespace mtk +{ +/** + * @brief Chord types, as semitone intervals from the root. + * + * Beyond the triads, the set and the interval spellings are those of the + * chord types of free-midi-chords by Ludovic Drolez + * (https://github.com/ldrolez/free-midi-chords, chords.py), MIT licence: + * + * Copyright (c) 2019-2026 Ludovic Drolez + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit + * persons to whom the Software is furnished to do so, subject to the + * following conditions: The above copyright notice and this permission + * notice shall be included in all copies or substantial portions of the + * Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. + * + * Their "2" (same notes as add9) and "dim6" (listed with the notes of dim7) + * are left out. + */ +struct chord_type +{ + std::string_view name; + std::array intervals; + int8_t count; + + std::span notes() const noexcept { return {intervals.data(), size_t(count)}; } +}; + +inline constexpr chord_type chord_types[] = { + // Triads and power chord + {"M", {0, 4, 7}, 3}, + {"m", {0, 3, 7}, 3}, + {"dim", {0, 3, 6}, 3}, + {"aug", {0, 4, 8}, 3}, + {"5", {0, 7}, 2}, + {"sus2", {0, 2, 7}, 3}, + {"sus4", {0, 5, 7}, 3}, + // With a major third + {"6", {0, 4, 7, 9}, 4}, + {"7", {0, 4, 7, 10}, 4}, + {"7-5", {0, 4, 6, 10}, 4}, + {"7+5", {0, 4, 8, 10}, 4}, + {"7sus4", {0, 5, 7, 10}, 4}, + {"maj7", {0, 4, 7, 11}, 4}, + {"M7+5", {0, 4, 8, 11}, 4}, + {"add4", {0, 4, 5, 7}, 4}, + {"add9", {0, 4, 7, 14}, 4}, + {"sus4add9", {0, 5, 7, 14}, 4}, + {"add11", {0, 4, 7, 17}, 4}, + {"69", {0, 4, 7, 9, 14}, 5}, + {"9", {0, 4, 7, 10, 14}, 5}, + {"maj9", {0, 4, 7, 11, 14}, 5}, + {"9sus4", {0, 5, 7, 10, 14}, 5}, + {"7-9", {0, 4, 7, 10, 13}, 5}, + {"7+11", {0, 4, 7, 10, 18}, 5}, + // With a minor third + {"m6", {0, 3, 7, 9}, 4}, + {"m7", {0, 3, 7, 10}, 4}, + {"m7-5", {0, 3, 6, 10}, 4}, + {"m7+5", {0, 3, 8, 10}, 4}, + {"dim7", {0, 3, 6, 9}, 4}, + {"mM7", {0, 3, 7, 11}, 4}, + {"madd4", {0, 3, 5, 7}, 4}, + {"madd9", {0, 3, 7, 14}, 4}, + {"m69", {0, 3, 7, 9, 14}, 5}, + {"m9", {0, 3, 7, 10, 14}, 5}, + {"m7b9b5", {0, 3, 6, 10, 13}, 5}, + {"m7add11", {0, 3, 7, 10, 17}, 5}, + {"mM7add11", {0, 3, 7, 11, 17}, 5}, +}; + +inline constexpr int chord_type_count = int(std::size(chord_types)); + +inline constexpr const chord_type* find_chord_type(std::string_view name) noexcept +{ + for(const auto& c : chord_types) + if(c.name == name) + return &c; + return nullptr; +} + +//! Seven-note modes and minor scales, as semitones from the tonic. +enum class mode : uint8_t +{ + ionian, + dorian, + phrygian, + lydian, + mixolydian, + aeolian, + locrian, + harmonic_minor, + melodic_minor +}; + +inline constexpr std::array, 9> mode_steps{{ + {0, 2, 4, 5, 7, 9, 11}, + {0, 2, 3, 5, 7, 9, 10}, + {0, 1, 3, 5, 7, 8, 10}, + {0, 2, 4, 6, 7, 9, 11}, + {0, 2, 4, 5, 7, 9, 10}, + {0, 2, 3, 5, 7, 8, 10}, + {0, 1, 3, 5, 6, 8, 10}, + {0, 2, 3, 5, 7, 8, 11}, + {0, 2, 3, 5, 7, 9, 11}, +}}; + +//! Semitones above the tonic of a scale degree, any integer (negative goes +//! down, 7 is the octave). +inline constexpr int degree_to_semitones(mode m, int degree) noexcept +{ + const auto& s = mode_steps[int(m)]; + int octave = degree >= 0 ? degree / 7 : -((-degree + 6) / 7); + int d = degree - octave * 7; + return octave * 12 + s[d]; +} + +//! The degree whose pitch class is closest to `semitones` (above the tonic), +//! ties going down. Returns the degree, octaves included. +inline constexpr int semitones_to_degree(mode m, int semitones) noexcept +{ + const int octave = semitones >= 0 ? semitones / 12 : -((-semitones + 11) / 12); + const int pc = semitones - octave * 12; + const auto& s = mode_steps[int(m)]; + int best = 0; + int best_dist = 99; + for(int d = 0; d < 8; d++) + { + const int st = d < 7 ? s[d] : 12; + const int dist = st > pc ? st - pc : pc - st; + if(dist < best_dist) + { + best = d; + best_dist = dist; + } + } + return octave * 7 + best; +} +} diff --git a/examples/Advanced/MidiScaler/MidiDelay.hpp b/examples/Advanced/MidiScaler/MidiDelay.hpp new file mode 100644 index 000000000..fb3819673 --- /dev/null +++ b/examples/Advanced/MidiScaler/MidiDelay.hpp @@ -0,0 +1,541 @@ +#pragma once + +/* SPDX-License-Identifier: GPL-3.0-or-later */ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace mtk +{ +/** + * @brief Echoes of the incoming notes. + * + * Each note-on starts a chain of echoes, Time apart. Echo k plays the source + * pitch plus k times Pitch step, at the source velocity times Feedback^k. The + * chain ends after Repeats echoes (unless Infinite), when the velocity falls + * under Vel. floor, or when the pitch leaves 0 - 127. + * An echo lasts as long as its source note, or Length with Fixed length. + * + * Dry/Wet follows Midihub: 0 plays the source only, 0.5 the source and the + * echoes at full velocity, 1 the echoes only; in between, the other side's + * velocity is scaled down. + * + * Echoes keep the source's channel. With MPE, the pitch bend and pressure that + * the source channel receives while the note is held are echoed with it. + * + * The settings in effect when a note starts apply to its whole chain, so that + * changing them never leaves an echo without its note-off. Up to 32 source + * notes are remembered; a 33rd ends the chain of the oldest. + * An echo of a pitch that still sounds ends it first, so that the output never + * starts a note twice. + */ +struct MidiDelay +{ + halp_meta(name, "Midi delay") + halp_meta(c_name, "midi_delay") + halp_meta(category, "Midi/Timing") + halp_meta(author, "ossia score") + halp_meta(description, "Repeat the incoming notes as echoes, with feedback and transposition") + halp_meta(manual_url, "https://ossia.io/score-docs/processes/midi-delay.html") + halp_meta(uuid, "5ba4e20c-8936-43ab-aee5-8ac4555e05c1") + halp_flag(process_exec); + + static constexpr int max_sources = 32; + static constexpr int max_expression_echoes = 16; + + struct ins + { + halp::midi_bus<"MIDI", libremidi::message> midi; + + struct : halp::time_chooser<"Time", halp::range{0.001, 4., 0.25}> + { + halp_meta(description, "Time between two echoes, free or synced to the tempo") + } time; + + struct : halp::spinbox_i32<"Repeats", halp::irange{1, 64, 3}> + { + halp_meta(description, "Number of echoes of each note") + } repeats; + + struct : halp::toggle<"Infinite"> + { + halp_meta(description, "Echo until the velocity falls under the floor") + } infinite; + + struct : halp::knob_f32<"Feedback", halp::range{0., 1., 0.7}> + { + halp_meta(description, "Velocity of each echo, relative to the previous one") + } feedback; + + struct : halp::spinbox_i32<"Vel. floor", halp::irange{1, 127, 1}> + { + halp_meta(description, "An echo quieter than this ends the chain") + } floor; + + struct : halp::spinbox_i32<"Pitch step", halp::irange{-24, 24, 0}> + { + halp_meta( + description, "Semitones added at each echo; the chain ends when a note " + "leaves the MIDI range") + } pitch_step; + + struct : halp::knob_f32<"Dry/Wet", halp::range{0., 1., 0.5}> + { + halp_meta( + description, "0: the source only. 0.5: the source and the echoes. 1: " + "the echoes only.") + } mix; + + struct : halp::toggle<"Fixed length"> + { + halp_meta(description, "Echoes last Length instead of the source note's length") + } fixed; + + struct : halp::time_chooser<"Length", halp::range{0.001, 4., 0.125}> + { + halp_meta(description, "Length of the echoes with Fixed length") + } length; + + struct : halp::toggle<"MPE"> + { + halp_meta( + description, "Echo the pitch bend and pressure of the source note's " + "channel with each echo") + } mpe; + } inputs; + + struct + { + halp::midi_bus<"MIDI", libremidi::message> midi; + } outputs; + + struct ui + { + halp_meta(name, "Midi delay") + halp_meta(layout, halp::layouts::hbox) + halp_meta(background, halp::colors::background_dark) + + struct + { + halp_meta(name, "Echo") + halp_meta(layout, halp::layouts::section) + halp_meta(background, halp::colors::background_mid) + struct + { + halp_meta(layout, halp::layouts::grid) + halp_meta(columns, 2) + halp::item<&ins::time> time; + halp::item<&ins::feedback> feedback; + struct + { + halp_meta(layout, halp::layouts::vbox) + static constexpr auto condition() + { + return halp::enabled_when<&ins::infinite, false>{}; + } + halp::item<&ins::repeats> repeats; + } repeats; + halp::item<&ins::infinite> infinite; + halp::item<&ins::floor> floor; + halp::item<&ins::pitch_step> pitch_step; + } controls; + } echo; + + struct + { + halp_meta(name, "Output") + halp_meta(layout, halp::layouts::section) + halp_meta(background, halp::colors::background_mid) + struct + { + halp_meta(layout, halp::layouts::grid) + halp_meta(columns, 2) + halp::item<&ins::mix> mix; + struct + { + halp_meta(layout, halp::layouts::vbox) + static constexpr auto condition() + { + return halp::enabled_when<&ins::fixed, true>{}; + } + halp::item<&ins::length> length; + } length; + halp::item<&ins::mpe> mpe; + halp::item<&ins::fixed> fixed; + } controls; + } output; + }; + + void prepare(halp::setup s) noexcept + { + if(s.rate > 0.) + m_rate = s.rate; + } + + void stop() noexcept { m_stop_requested = true; } + + using tick = halp::tick_musical; + void operator()(const halp::tick_musical& t) noexcept + { + m_frames = t.frames > 0 ? t.frames : 0; + if(m_stop_requested) + { + m_stop_requested = false; + end_all(0, 0); + } + + for(const auto& m : inputs.midi) + { + const int64_t ts + = m_frames > 0 ? std::clamp(m.timestamp, 0, m_frames - 1) : 0; + advance(ts); + handle(m, ts); + } + advance(m_frames); + m_now += m_frames; + } + + //! Source notes whose echoes are not all out yet. + int active_sources() const noexcept + { + int n = 0; + for(auto& s : m_sources) + n += s.used; + return n; + } + +private: + struct source + { + bool used{}; + bool dry{}; //!< its note-on went out + bool done{}; //!< no echo left to start + uint8_t channel{}; //!< 1 - 16 + uint8_t pitch{}; + uint8_t velocity{}; + uint8_t off_velocity{}; + uint32_t serial{}; + int64_t on_at{}; + int64_t off_at{-1}; //!< -1 while the key is held + int64_t delay{}; + int64_t fixed_length{-1}; //!< -1: as long as the source + int repeats{}; //!< -1: infinite + int floor{}; + int step{}; + double feedback{}; + double wet{}; + int next_on{1}; //!< next echo to start + int next_off{1}; //!< next echo to end + + //! Velocity of echo k, or 0 if the chain has ended before it. + int echo_velocity(int k) const noexcept + { + if(repeats >= 0 && k > repeats) + return 0; + const int p = pitch + k * step; + if(p < 0 || p > 127) + return 0; + const int v = int(std::lround(velocity * wet * std::pow(feedback, k))); + return v >= std::max(1, floor) ? std::min(v, 127) : 0; + } + int echo_pitch(int k) const noexcept { return pitch + k * step; } + int64_t echo_on(int k) const noexcept { return on_at + k * delay; } + int64_t echo_off(int k) const noexcept + { + return fixed_length >= 0 ? echo_on(k) + fixed_length : off_at + k * delay; + } + bool off_known() const noexcept { return fixed_length >= 0 || off_at >= 0; } + }; + + // ------------------------------------------------------------------ output + + //! Per channel and pitch, how many notes sound. A note-on over a sounding + //! pitch re-attacks it; the pitch ends with the last of its notes. + void send_on(int c, int p, int v, int64_t ts) noexcept + { + auto& n = m_sounding[(c - 1) & 15][p & 127]; + if(n > 0) + push(libremidi::channel_events::note_off(c, p, 0), ts); + if(n < 0xFFFF) + n++; + push(libremidi::channel_events::note_on(c, p, v), ts); + } + + void send_off(int c, int p, int v, int64_t ts) noexcept + { + auto& n = m_sounding[(c - 1) & 15][p & 127]; + if(n == 0) + return; + if(--n == 0) + push(libremidi::channel_events::note_off(c, p, v), ts); + } + + void push(libremidi::message m, int64_t ts) noexcept + { + m.timestamp = ts; + outputs.midi.push_back(std::move(m)); + } + + void send(libremidi::message&& m) noexcept + { + const int ts = int(m.timestamp); + if(is_note_on(m)) + send_on(channel_of(m), m.bytes[1], m.bytes[2], ts); + else if(is_note_off(m)) + send_off(channel_of(m), m.bytes[1], m.bytes[2], ts); + else + push(std::move(m), ts); + } + + // --------------------------------------------------------------- schedule + + //! Queues the echoes starting or ending before `limit`, then sends what is + //! due before `ts` in the tick. + void advance(int64_t ts) noexcept + { + if(m_frames <= 0) + return; + const int64_t limit = m_now + ts; + for(auto& s : m_sources) + { + if(!s.used) + continue; + while(!s.done) + { + const int k = s.next_on; + const int v = s.echo_velocity(k); + if(v == 0) + { + s.done = true; + break; + } + if(s.echo_on(k) >= limit) + break; + // An echo whose note-on is lost must not count as started: its + // note-off would end another note of the same pitch. + if(!make_room_for_note() + || !m_queue.schedule( + s.echo_on(k), + libremidi::channel_events::note_on(s.channel, s.echo_pitch(k), v))) + break; + s.next_on++; + } + if(s.off_known()) + { + while(s.next_off < s.next_on && s.echo_off(s.next_off) < limit) + { + const int k = s.next_off++; + make_room_for_note(); + m_queue.schedule( + std::max(s.echo_off(k), s.echo_on(k)), + libremidi::channel_events::note_off( + s.channel, s.echo_pitch(k), s.off_velocity)); + } + } + if(s.done && s.off_at >= 0 && s.next_off == s.next_on) + s = {}; + } + m_queue.release(m_now, int(ts), [this](libremidi::message&& m) { send(std::move(m)); }); + } + + //! Expression echoes give way to notes when the queue is full. + bool make_room_for_note() noexcept + { + return m_queue.make_room([](const auto& e) { + return !is_note_on(e.msg) && !is_note_off(e.msg); + }); + } + + int64_t frames_of(float seconds) const noexcept + { + return int64_t(std::llround(std::max(0.f, seconds) * m_rate)); + } + + // ----------------------------------------------------------------- input + + void handle(const libremidi::message& m, int64_t ts) noexcept + { + switch(classify(m)) + { + case midi_kind::note_on: + note_on(channel_of(m), m.bytes[1] & 127, m.bytes[2] & 127, ts); + return; + case midi_kind::note_off: + if(m.size() >= 3) + { + const int vel = (m.bytes[0] & 0xF0) == 0x80 ? (m.bytes[2] & 127) : 0; + note_off(channel_of(m), m.bytes[1] & 127, vel, ts); + return; + } + break; + case midi_kind::control_change: + if(is_all_notes_off(m)) + { + end_all(channel_of(m), ts); + push(m, ts); + return; + } + break; + case midi_kind::pitch_bend: + case midi_kind::channel_pressure: + case midi_kind::poly_pressure: + if(inputs.mpe.value) + echo_expression(m, ts); + break; + default: + break; + } + push(m, ts); + } + + void note_on(int c, int p, int v, int64_t ts) noexcept + { + source* slot = nullptr; + for(auto& s : m_sources) + if(!s.used) + { + slot = &s; + break; + } + if(!slot) + { + slot = &m_sources[0]; + for(auto& s : m_sources) + if(s.serial < slot->serial) + slot = &s; + retire(*slot, ts); + } + + const double mix = std::clamp(double(inputs.mix.value), 0., 1.); + const double dry = mix <= 0.5 ? 1. : (1. - mix) * 2.; + const double wet = mix >= 0.5 ? 1. : mix * 2.; + + source& s = *slot; + s = {}; + s.used = true; + s.channel = uint8_t(c); + s.pitch = uint8_t(p); + s.velocity = uint8_t(v); + s.serial = m_serial++; + s.on_at = m_now + ts; + s.delay = frames_of(inputs.time.value); + s.fixed_length + = inputs.fixed.value ? std::max(1, frames_of(inputs.length.value)) : -1; + s.repeats = inputs.infinite.value ? -1 : std::max(0, inputs.repeats.value); + s.floor = inputs.floor.value; + s.step = inputs.pitch_step.value; + s.feedback = std::clamp(double(inputs.feedback.value), 0., 1.); + s.wet = wet; + if(s.delay < 1) + s.done = true; + + const int dv = int(std::lround(v * dry)); + if(dv >= 1) + { + s.dry = true; + send_on(c, p, dv, ts); + } + } + + void note_off(int c, int p, int vel, int64_t ts) noexcept + { + // Every chain of the key that is still held: a key struck twice without + // release ends with its one note-off + for(auto& s : m_sources) + { + if(!s.used || s.off_at >= 0 || s.channel != c || s.pitch != p) + continue; + s.off_at = m_now + ts; + s.off_velocity = uint8_t(vel); + if(s.dry) + send_off(c, p, vel, ts); + if(s.done && s.next_off == s.next_on) + s = {}; + } + } + + //! The oldest chain makes room: what it started ends now. + void retire(source& s, int64_t ts) noexcept + { + if(s.off_at < 0 && s.dry) + send_off(s.channel, s.pitch, 0, ts); + // advance() leaves nothing of a chain in the queue: its echoes started + // and not ended yet are these + for(int k = s.next_off; k < s.next_on; k++) + send_off(s.channel, s.echo_pitch(k), 0, ts); + s = {}; + } + + //! Pitch bend and pressure of a held note's channel, replayed with its + //! echoes (the latest held note of the channel, for a poly pressure the one + //! of that key). + void echo_expression(const libremidi::message& m, int64_t ts) noexcept + { + const int c = channel_of(m); + const bool poly = classify(m) == midi_kind::poly_pressure; + const source* src = nullptr; + for(auto& s : m_sources) + { + if(!s.used || s.off_at >= 0 || s.channel != c) + continue; + if(poly && s.pitch != m.bytes[1]) + continue; + if(!src || s.serial > src->serial) + src = &s; + } + if(!src) + return; + for(int k = 1; k <= max_expression_echoes && src->echo_velocity(k) > 0; k++) + { + auto e = m; + if(poly) + e.bytes[1] = uint8_t(src->echo_pitch(k)); + m_queue.schedule(m_now + ts + k * src->delay, e); + } + } + + //! Ends what sounds and forgets the chains, on one channel or (0) all. + void end_all(int channel, int64_t ts) noexcept + { + for(int c = 1; c <= 16; c++) + { + if(channel != 0 && c != channel) + continue; + for(int p = 0; p < 128; p++) + { + auto& n = m_sounding[c - 1][p]; + if(n > 0) + { + n = 0; + push(libremidi::channel_events::note_off(c, p, 0), ts); + } + } + } + for(auto& s : m_sources) + if(channel == 0 || s.channel == channel) + s = {}; + m_queue.remove_if( + [&](const auto& e) { return channel == 0 || channel_of(e.msg) == channel; }); + } + + std::array m_sources{}; + std::array, 16> m_sounding{}; + event_queue<2048> m_queue; + int64_t m_now{}; + double m_rate{48000.}; + uint32_t m_serial{}; + int m_frames{}; + bool m_stop_requested{}; +}; +} diff --git a/examples/Advanced/MidiScaler/MidiDispatcher.hpp b/examples/Advanced/MidiScaler/MidiDispatcher.hpp new file mode 100644 index 000000000..000ad37ca --- /dev/null +++ b/examples/Advanced/MidiScaler/MidiDispatcher.hpp @@ -0,0 +1,543 @@ +#pragma once + +/* SPDX-License-Identifier: GPL-3.0-or-later */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace mtk +{ +/** + * @brief Spreads incoming notes over a set of MIDI channels, to play a + * polyphonic part on several monophonic synths or to split a chord between + * instruments. + * + * Note-offs follow their note's channel. In the chord modes, notes are held + * back until no new note came for the grace period, then the whole chord is + * dispatched at once, ordered by pitch. + */ +struct MidiDispatcher +{ + halp_meta(name, "Midi dispatcher") + halp_meta(c_name, "midi_dispatcher") + halp_meta(category, "Midi/Routing") + halp_meta(author, "ossia score") + halp_meta(description, "Spread incoming notes over a set of MIDI channels") + halp_meta(manual_url, "https://ossia.io/score-docs/processes/midi-dispatcher.html") + halp_meta(uuid, "f8d568dd-1dd4-4aad-9b49-03628f874557") + halp_flag(process_exec); + + static constexpr int max_chord = 32; + + struct algorithm_control + { + static clang_buggy_consteval auto name() { return std::string_view{"Algorithm"}; } + halp_meta( + description, + "Round robin: next channel of the set. Random: any free channel. " + "Ping pong: back and forth over the set. Chord modes: the notes of a " + "chord go to the channels of the set in order, in the order they were " + "played, from the lowest note, or from the highest note.") + enum enum_type + { + round_robin, + random, + ping_pong, + chord, + chord_ascending, + chord_descending + } value{round_robin}; + enum widget + { + combobox + }; + struct range + { + std::string_view values[6]{"Round robin", "Random", "Ping pong", "Chord", + "Chord ascending", "Chord descending"}; + enum_type init = round_robin; + }; + operator enum_type&() noexcept { return value; } + operator const enum_type&() const noexcept { return value; } + auto& operator=(enum_type t) noexcept + { + value = t; + return *this; + } + }; + + struct ins + { + halp::midi_bus<"MIDI", libremidi::message> midi; + + algorithm_control algorithm; + + struct : halp::channel_mask<"Channels", 0x000F> + { + halp_meta(description, "Channels the notes are spread over") + } channels; + + struct : halp::spinbox_i32<"Surplus channel", halp::range{0, 16, 0}> + { + halp_meta( + description, + "Channel for the notes that find no free channel. 0: they take the " + "channel released longest ago, or else end the oldest note (in the " + "chord modes, the note on the channel of the same rank)") + } surplus; + + struct : halp::hslider_f32<"Grace (ms)", halp::range{0., 200., 20.}> + { + halp_meta( + description, + "Chord modes: time in ms without a new note after which the chord is " + "complete and dispatched") + } grace; + + struct : halp::hslider_f32<"Release reserve (ms)", halp::range{0., 1000., 0.}> + { + halp_meta( + description, + "Time in ms during which a channel stays reserved after its note ends, " + "for the release of the previous note") + } reserve; + + struct : halp::spinbox_i32<"Seed", halp::range{0, 10000, 0}> + { + halp_meta(description, "Random mode: 0 is free running, other values repeat") + } seed; + + struct : halp::toggle<"Copy to all"> + { + halp_meta( + description, + "Copy controllers, pressure, pitch bend and program changes to every " + "channel of the set and to the surplus channel, instead of passing " + "them unchanged") + } copy_to_all; + } inputs; + + struct ui + { + halp_meta(name, "Midi dispatcher") + halp_meta(layout, halp::layouts::hbox) + halp_meta(background, halp::colors::background_dark) + + struct + { + halp_meta(name, "Spread") + halp_meta(layout, halp::layouts::section) + halp_meta(background, halp::colors::background_mid) + halp::item<&ins::algorithm> algorithm; + halp::item<&ins::channels> channels; + halp::item<&ins::surplus> surplus; + halp::item<&ins::copy_to_all> copy_to_all; + } spread; + + struct + { + halp_meta(name, "Timing") + halp_meta(layout, halp::layouts::section) + halp_meta(background, halp::colors::background_mid) + + struct + { + halp_meta(layout, halp::layouts::vbox) + static constexpr auto condition() + { + return halp::enabled_when< + &ins::algorithm, algorithm_control::chord, algorithm_control::chord_ascending, + algorithm_control::chord_descending>{}; + } + halp::item<&ins::grace> grace; + } chord; + + halp::item<&ins::reserve> reserve; + + struct + { + halp_meta(layout, halp::layouts::vbox) + static constexpr auto condition() + { + return halp::enabled_when<&ins::algorithm, algorithm_control::random>{}; + } + halp::item<&ins::seed> seed; + } random; + } timing; + }; + + struct + { + halp::midi_bus<"MIDI", libremidi::message> midi; + } outputs; + + MidiDispatcher() + { + std::random_device rd; + m_free_seed[0] = (uint64_t(rd()) << 32) ^ uint64_t(rd()); + m_free_seed[1] = (uint64_t(rd()) << 32) ^ uint64_t(rd()); + reseed(); + } + + void prepare(halp::setup info) noexcept + { + if(info.rate > 0.) + m_rate = info.rate; + } + + //! Sends a message, and frees the channel of each note-off it sends. + auto emitter() noexcept + { + return [this](libremidi::message&& m) noexcept { + if(is_note_off(m)) + { + const int ch = channel_of(m); + if(m_taken[ch - 1] > 0) + { + m_taken[ch - 1]--; + m_alloc.release(ch, m_now + m.timestamp); + } + } + outputs.midi.push_back(std::move(m)); + }; + } + + void stop() noexcept { m_stop = true; } + + using tick = halp::tick; + void operator()(halp::tick t) noexcept + { + m_frames = t.frames > 0 ? t.frames : 0; + + if(m_stop) + { + m_chord_size = 0; + m_notes.end_all(0, emitter()); + m_alloc.reset(); + m_taken = {}; + reseed(); + m_stop = false; + } + + configure(); + + for(const auto& in : inputs.midi) + { + const int64_t ts = m_frames > 0 ? std::clamp(in.timestamp, 0, m_frames - 1) : 0; + if(m_chord_size > 0 && m_deadline < m_now + ts) + dispatch_chord(m_deadline); + handle(in, ts); + } + + if(m_chord_size > 0 && m_deadline < m_now + m_frames) + dispatch_chord(m_deadline); + + m_now += m_frames; + } + +private: + struct pending_note + { + uint8_t channel{}, pitch{}, velocity{}; + }; + + bool chord_mode() const noexcept + { + return m_algorithm == algorithm_control::chord + || m_algorithm == algorithm_control::chord_ascending + || m_algorithm == algorithm_control::chord_descending; + } + + void configure() noexcept + { + m_alloc.set_channels(inputs.channels.mask()); + + const auto algo = inputs.algorithm.value; + if(algo != m_algorithm) + { + // A chord being collected goes out with the settings it was played with. + if(m_chord_size > 0) + dispatch_chord(m_now); + m_algorithm = algo; + } + switch(m_algorithm) + { + case algorithm_control::round_robin: + m_alloc.set_mode(channel_allocator::mode::round_robin); + break; + case algorithm_control::random: + m_alloc.set_mode(channel_allocator::mode::random); + break; + case algorithm_control::ping_pong: + m_alloc.set_mode(channel_allocator::mode::ping_pong); + break; + default: + m_alloc.set_mode(channel_allocator::mode::lowest); + break; + } + + m_alloc.set_release_reserve( + int64_t(std::llround(std::clamp(double(inputs.reserve.value), 0., 1000.) * m_rate / 1000.))); + m_grace = int64_t( + std::llround(std::clamp(double(inputs.grace.value), 0., 200.) * m_rate / 1000.)); + m_surplus = std::clamp(int(inputs.surplus.value), 0, 16); + + if(inputs.seed.value != m_seed) + { + m_seed = inputs.seed.value; + reseed(); + } + } + + void reseed() noexcept + { + if(m_seed != 0) + m_rng.seed(uint64_t(m_seed), UINT64_C(0x9e3779b97f4a7c15)); + else + m_rng.seed(m_free_seed[0], m_free_seed[1]); + } + + double random01() noexcept { return double(m_rng()) / 4294967296.0; } + + int64_t offset_of(int64_t at) const noexcept + { + return m_frames > 0 ? std::clamp(at - m_now, 0, m_frames - 1) : 0; + } + + void handle(const libremidi::message& m, int64_t ts) noexcept + { + auto emit = emitter(); + switch(classify(m)) + { + case midi_kind::note_on: { + if(chord_mode()) + { + if(m_chord_size == max_chord) + dispatch_chord(m_now + ts); + m_chord[m_chord_size++] + = {uint8_t(channel_of(m)), m.bytes[1], m.bytes[2]}; + m_deadline = m_now + ts + m_grace; + } + else + { + auto ch = m_alloc.allocate(m_now + ts, [this] { return random01(); }); + start_note(channel_of(m), m.bytes[1], m.bytes[2], ch, ts); + } + break; + } + + case midi_kind::note_off: { + const int ch = channel_of(m); + if(m_chord_size > 0) + { + // The chord this note belongs to is complete. + for(int i = 0; i < m_chord_size; i++) + { + if(m_chord[i].channel == ch && m_chord[i].pitch == m.bytes[1]) + { + dispatch_chord(m_now + ts); + break; + } + } + } + if(!m_notes.end(ch, m.bytes[1], ts, m.bytes[2], emit)) + { + // Not a note this object started: its channel stays as it is. + auto msg = m; + msg.timestamp = ts; + outputs.midi.push_back(std::move(msg)); + } + break; + } + + case midi_kind::control_change: + case midi_kind::program_change: + case midi_kind::pitch_bend: + case midi_kind::channel_pressure: + case midi_kind::poly_pressure: { + if(is_all_notes_off(m)) + { + m_chord_size = 0; + m_notes.end_all(ts, emit); + } + + if(inputs.copy_to_all.value && classify(m) != midi_kind::poly_pressure) + { + uint16_t targets = m_alloc.channels(); + if(m_surplus > 0) + targets |= uint16_t(1u << (m_surplus - 1)); + for(int ch = 1; ch <= 16; ch++) + { + if((targets >> (ch - 1)) & 1) + { + auto msg = m; + set_channel(msg, ch); + msg.timestamp = ts; + emit(std::move(msg)); + } + } + } + else if(classify(m) == midi_kind::poly_pressure && m.size() >= 3) + { + // Follows the note it applies to when that note is held. + auto msg = m; + msg.timestamp = ts; + for(int i = 0; i < m_notes.size(); i++) + { + const auto& h = m_notes[i]; + if(h.channel == channel_of(m) && h.pitch == m.bytes[1] && h.count > 0) + { + set_channel(msg, h.outputs[0].channel); + break; + } + } + emit(std::move(msg)); + } + else + { + auto msg = m; + msg.timestamp = ts; + emit(std::move(msg)); + } + break; + } + + default: { + auto msg = m; + msg.timestamp = ts; + emit(std::move(msg)); + break; + } + } + } + + //! A channel for a note that found none free, when there is no surplus + //! channel: the one released longest ago, or else the channel of a sounding + //! note, which is ended. The stolen input note stays tracked without output, + //! so that its own note-off is swallowed. + std::optional steal(int64_t ts, int rank) noexcept + { + if(auto ch = m_alloc.oldest_released()) + { + m_alloc.take(*ch); + return ch; + } + + std::optional target = rank >= 0 ? m_alloc.nth_usable(rank) : std::nullopt; + int victim = -1; + for(int i = 0; i < m_notes.size(); i++) + { + const auto& h = m_notes[i]; + if(h.count == 0) + continue; + if(!target || h.outputs[0].channel == *target) + { + victim = i; + break; + } + } + if(victim < 0) + { + // The channel of that rank holds no note: fall back to the oldest one. + for(int i = 0; i < m_notes.size() && victim < 0; i++) + if(m_notes[i].count > 0) + victim = i; + if(victim < 0) + return std::nullopt; + } + + const int ch = m_notes[victim].outputs[0].channel; + const int in_ch = m_notes[victim].channel; + const int in_pitch = m_notes[victim].pitch; + auto emit = emitter(); + m_notes.end_at(victim, ts, 0, emit); + m_notes.begin(in_ch, in_pitch, ts, emit); + m_alloc.take(ch); + return ch; + } + + void start_note(int in_ch, int pitch, int vel, std::optional ch, int64_t ts, int rank = -1) noexcept + { + auto emit = emitter(); + if(!ch && m_surplus == 0) + ch = steal(ts, rank); + + // A note with no channel is still tracked, without output, so that its + // note-off is swallowed too. + auto& h = m_notes.begin(in_ch, pitch, ts, emit); + if(!ch) + { + if(m_surplus == 0) + return; + if(m_notes.sounding(m_surplus, pitch) == 0) + { + m_alloc.take(m_surplus); + m_taken[m_surplus - 1]++; + } + m_notes.play(h, m_surplus, pitch, vel, ts, emit); + return; + } + m_taken[*ch - 1]++; + m_notes.play(h, *ch, pitch, vel, ts, emit); + } + + void dispatch_chord(int64_t at) noexcept + { + const int64_t ts = offset_of(at); + const int n = m_chord_size; + m_chord_size = 0; + + auto* first = m_chord.data(); + if(m_algorithm == algorithm_control::chord_ascending) + std::stable_sort(first, first + n, [](const auto& a, const auto& b) { + return a.pitch < b.pitch; + }); + else if(m_algorithm == algorithm_control::chord_descending) + std::stable_sort(first, first + n, [](const auto& a, const auto& b) { + return a.pitch > b.pitch; + }); + + for(int i = 0; i < n; i++) + { + auto ch = m_alloc.allocate(m_now + ts); + start_note(m_chord[i].channel, m_chord[i].pitch, m_chord[i].velocity, ch, ts, i); + } + } + + note_tracker<128, 1> m_notes; + channel_allocator m_alloc; + //! Note-ons sent on each channel and not yet ended. + std::array m_taken{}; + + std::array m_chord{}; + int m_chord_size{}; + int64_t m_deadline{}; + + rnd::pcg m_rng; + uint64_t m_free_seed[2]{}; + int m_seed{0}; + + algorithm_control::enum_type m_algorithm{algorithm_control::round_robin}; + int m_surplus{}; + int64_t m_grace{}; + double m_rate{48000.}; + int64_t m_now{}; + int m_frames{}; + bool m_stop{false}; +}; +} diff --git a/examples/Advanced/MidiScaler/MidiEnvelope.hpp b/examples/Advanced/MidiScaler/MidiEnvelope.hpp index 2de4ad620..d072352d4 100644 --- a/examples/Advanced/MidiScaler/MidiEnvelope.hpp +++ b/examples/Advanced/MidiScaler/MidiEnvelope.hpp @@ -48,7 +48,7 @@ struct MidiEnvelope public: halp_meta(name, "Midi Envelope") halp_meta(c_name, "midi_envelope") - halp_meta(category, "Midi") + halp_meta(category, "Midi/Control") halp_meta(author, "ossia score") halp_meta(description, "Turn MIDI notes into control-rate envelopes") halp_meta(manual_url, "https://ossia.io/score-docs/processes/midi-envelope.html") @@ -132,7 +132,7 @@ struct MidiEnvelope halp::time_chooser<"Decay", halp::range{0., 20., 0.2}> decay; - struct : halp::hslider_f32<"Sustain", halp::range{0., 1., 0.7}> + struct : halp::knob_f32<"Sustain", halp::range{0., 1., 0.7}> { halp_meta(description, "Level held while the note is down, relative to the peak") } sustain; @@ -144,7 +144,7 @@ struct MidiEnvelope halp_meta(description, "Time taken to traverse the drawn curve (Curve modes)") } curve_duration; - struct : halp::hslider_f32<"Sustain point", halp::range{0., 1., 0.5}> + struct : halp::knob_f32<"Sustain point", halp::range{0., 1., 0.5}> { halp_meta(description, "Position in the drawn curve held while the note is down") } sustain_point; diff --git a/examples/Advanced/MidiScaler/MidiFilter.hpp b/examples/Advanced/MidiScaler/MidiFilter.hpp index b82487858..b7f04b4b4 100644 --- a/examples/Advanced/MidiScaler/MidiFilter.hpp +++ b/examples/Advanced/MidiScaler/MidiFilter.hpp @@ -2,24 +2,312 @@ /* SPDX-License-Identifier: GPL-3.0-or-later */ +#include + #include #include #include +#include #include +#include #include #include #include #include +#include +#include #include #include #include #include #include +#include + +#include +#include +#include namespace mtk { /** - * This example extracts various kind of MIDI events + * @brief A set of message kinds written as text: "note, cc, bend, -sysex", + * what the Types of the Midi filter were before they were toggles. + * + * Tokens are separated by commas or spaces, case does not matter. A token + * prefixed with '-' or '!' removes kinds; a list starting with a removal + * starts from every kind. Unknown tokens are ignored. Group names: all, + * channel, system, realtime, transport, note. + */ +struct kind_set +{ + uint32_t mask{}; + bool empty{true}; + + bool contains(midi_kind k) const noexcept { return mask & (1u << int(k)); } + + static constexpr uint32_t bit(midi_kind k) noexcept { return 1u << int(k); } + + static uint32_t lookup(std::string_view t) noexcept + { + using enum midi_kind; + constexpr uint32_t all = (1u << midi_kind_count) - 1; + constexpr uint32_t channel = bit(note_on) | bit(note_off) | bit(control_change) + | bit(program_change) | bit(pitch_bend) + | bit(channel_pressure) | bit(poly_pressure); + constexpr uint32_t transport = bit(start) | bit(continue_) | bit(stop); + constexpr uint32_t realtime + = bit(clock) | transport | bit(active_sensing) | bit(reset); + struct entry + { + std::string_view name; + uint32_t mask; + }; + static constexpr entry table[] = { + {"all", all}, + {"channel", channel}, + {"system", all & ~channel}, + {"realtime", realtime}, + {"transport", transport}, + {"note", bit(note_on) | bit(note_off)}, + {"notes", bit(note_on) | bit(note_off)}, + {"noteon", bit(note_on)}, + {"noteoff", bit(note_off)}, + {"cc", bit(control_change)}, + {"control", bit(control_change)}, + {"program", bit(program_change)}, + {"pc", bit(program_change)}, + {"bend", bit(pitch_bend)}, + {"pb", bit(pitch_bend)}, + {"pressure", bit(channel_pressure)}, + {"aftertouch", bit(channel_pressure)}, + {"at", bit(channel_pressure)}, + {"poly", bit(poly_pressure)}, + {"sysex", bit(sysex)}, + {"mtc", bit(time_code)}, + {"spp", bit(song_position)}, + {"song", bit(song_select)}, + {"tune", bit(tune_request)}, + {"clock", bit(clock)}, + {"start", bit(start)}, + {"continue", bit(continue_)}, + {"stop", bit(stop)}, + {"sensing", bit(active_sensing)}, + {"reset", bit(reset)}, + }; + for(const auto& e : table) + if(e.name == t) + return e.mask; + return 0; + } + + void parse(std::string_view s) noexcept + { + mask = 0; + empty = true; + bool first = true; + std::size_t i = 0; + while(i < s.size()) + { + while(i < s.size() + && (s[i] == ',' || s[i] == ' ' || s[i] == '\t' || s[i] == ';')) + i++; + if(i >= s.size()) + break; + + bool remove = false; + if(s[i] == '-' || s[i] == '!') + { + remove = true; + i++; + } + + char buf[16]{}; + std::size_t n = 0; + while(i < s.size() && s[i] != ',' && s[i] != ' ' && s[i] != '\t' && s[i] != ';') + { + char c = s[i++]; + if(c >= 'A' && c <= 'Z') + c = char(c - 'A' + 'a'); + if(c == '_' || c == '-') + continue; + if(n < sizeof(buf)) + buf[n] = c; + n++; + } + if(n > sizeof(buf)) + continue; + + const uint32_t k = lookup(std::string_view{buf, n}); + if(k == 0) + continue; + if(remove) + { + if(first) + mask = (1u << midi_kind_count) - 1; + mask &= ~k; + } + else + { + mask |= k; + } + first = false; + empty = false; + } + } +}; + +//! An inclusive range of data byte values, a row of a range list control. +struct byte_range +{ + int from{}; + int to{}; +}; + +/** + * @brief The rows of a range list control: a value is in when a row holds + * it. No rows: inactive. + */ +struct byte_ranges +{ + static constexpr int max_rows = range_list::max_ranges; + std::array rows{}; + int count{}; + + bool empty() const noexcept { return count == 0; } + + void assign(const auto& list, int min, int max) noexcept + { + count = 0; + for(byte_range r : list) + { + if(count >= max_rows) + break; + const int a = std::clamp(r.from, min, max); + const int b = std::clamp(r.to, min, max); + rows[count++] = {std::min(a, b), std::max(a, b)}; + } + } + + bool contains(int v) const noexcept + { + for(int i = 0; i < count; i++) + if(v >= rows[i].from && v <= rows[i].to) + return true; + return false; + } +}; + +//! A linear map from [in_from, in_to] onto [out_from, out_to]. +struct range_map_row +{ + int in_from{}; + int in_to{}; + int out_from{}; + int out_to{}; +}; + +/** + * @brief The linear maps of a list control: the first row whose input range + * holds a value maps it. No rows: inactive. + */ +struct range_maps +{ + static constexpr int max_rows = 4; + std::array rows{}; + int count{}; + + bool active() const noexcept { return count > 0; } + + void assign(const auto& list, int min, int max) noexcept + { + count = 0; + for(range_map_row r : list) + { + if(count >= max_rows) + break; + r.in_from = std::clamp(r.in_from, min, max); + r.in_to = std::clamp(r.in_to, min, max); + r.out_from = std::clamp(r.out_from, min, max); + r.out_to = std::clamp(r.out_to, min, max); + rows[count++] = r; + } + } + + const range_map_row* find(int v) const noexcept + { + for(int i = 0; i < count; i++) + { + const auto& r = rows[i]; + if(v >= std::min(r.in_from, r.in_to) && v <= std::max(r.in_from, r.in_to)) + return &r; + } + return nullptr; + } + + //! Rounds to the nearest output value. + static int map(const range_map_row& r, int v) noexcept + { + if(r.in_to == r.in_from) + return r.out_from; + const double t = double(v - r.in_from) / double(r.in_to - r.in_from); + return r.out_from + int(std::floor(t * (r.out_to - r.out_from) + 0.5)); + } +}; + +/** + * @brief Rows of inclusive 0 - 127 ranges, the Notes and CCs of the Midi + * filter. They were texts such as "1-4, 6", or "C-1-B2, C4" with NoteNames. + */ +template +struct range_rows : halp::value_list +{ + static constexpr std::array columns() + { + return { + halp::list_column{.name = "From", .min = 0, .max = 127, .init = 0}, + halp::list_column{.name = "To", .min = 0, .max = 127, .init = 127}}; + } + + using value_type = decltype(halp::value_list::value); + + //! The rows a text of ranges stands for; none when it does not parse, as + //! such a text restricted nothing. + static value_type from_text(std::string_view text) noexcept + { + range_list list; + value_type res; + if(!list.parse(text, 0, 127, NoteNames)) + for(int i = 0; i < list.count; i++) + res.push_back({list.ranges[i].lo, list.ranges[i].hi}); + return res; + } + + static ossia::value upgrade_value(const ossia::value& old) + { + const auto* text = old.target(); + if(!text) + return old; + std::vector res; + for(const auto& r : from_text(*text)) + res.push_back(std::vector{r.from, r.to}); + return res; + } +}; + +/** + * @brief Filters MIDI messages by kind, channel, note, controller and value, + * remaps channels, notes and controller numbers, and extracts values. + * + * Messages that are not kept go to the "Rejected" output, so the object also + * splits a keyboard or a set of channels. + * + * "Types" are toggles, one per kind of message; none on leaves the choice to + * "Filter type". "Notes" and "CCs" are rows of inclusive ranges, "Value range" + * a text such as "1-63, 100". An empty list, as every channel on in + * "Channels", does not restrict anything. + * + * A note-off always goes to the output its note-on went to, with the same + * remapped channel and note, even when the settings changed in between. */ struct MidiFilter { @@ -27,8 +315,13 @@ struct MidiFilter halp_meta(c_name, "avnd_helpers_midifilter") halp_meta(manual_url, "https://ossia.io/score-docs/processes/midi-filter.html") halp_meta(uuid, "f66d6cf7-693a-47d4-a249-5978cc946e43") - halp_meta(category, "Midi") - halp_meta(description, "Filter MIDI events by type, channel and index and expose raw or normalized values.") + halp_flag(process_exec); + halp_meta(category, "Midi/Routing") + halp_meta(author, "ossia score") + halp_meta( + description, + "Filter MIDI by kind, channel, note, controller and value, remap channels, " + "notes and controllers, and expose raw or normalized values.") enum Mode { @@ -47,6 +340,16 @@ struct MidiFilter NoteAny, NoteRunning }; + enum ValueTest + { + Inside, + Outside + }; + enum OutOfMap + { + Pass, + Drop + }; struct note_int { @@ -59,10 +362,16 @@ struct MidiFilter float velocity{}; }; - struct + struct ins { halp::midi_bus<"MIDI messages", libremidi::message> midi; - halp::enum_t filter; + struct : halp::enum_t + { + halp_meta( + description, + "Kind of message kept when no Types toggle is on, and kind of the value " + "outputs") + } filter; struct : halp::spinbox_i32<"Channel", halp::range{0, 16, 0}> { halp_meta(description, "Channel (0 = ALL)") @@ -74,6 +383,130 @@ struct MidiFilter halp::enum_t mode; halp::toggle<"Note off to zero"> note_off_to_zero; halp::toggle<"Zero to note off"> zero_to_note_off; + + //! One toggle per midi_kind, in its order. Undefined status bytes and + //! truncated messages, which are of no kind, are kept when every kind is. + struct kinds_grid : halp::toggle_grid<"Types", midi_kind_count - 1> + { + halp_meta( + description, + "Kinds of message kept: notes, controllers, program changes, pitch bend, " + "channel and poly pressure, sysex, MIDI time code, song position, song " + "select, tune request, and the real-time clock, start, continue, stop, " + "active sensing and reset. None on: Filter type decides.") + static constexpr std::array labels() + { + return {"Note on", "Note off", "CC", "Program", "Bend", "Pressure", + "Poly", "Sysex", "MTC", "SPP", "Song", "Tune", + "Clock", "Start", "Continue", "Stop", "Sensing", "Reset"}; + } + static constexpr int items_per_line() { return 6; } + + //! The toggles a text of kinds such as "note, cc, -sysex" turns on. + static std::array from_text(std::string_view text) noexcept + { + kind_set k; + k.parse(text); + std::array res{}; + for(std::size_t i = 0; i < res.size(); i++) + res[i] = k.contains(midi_kind(i)); + return res; + } + + //! Types was such a text. + static ossia::value upgrade_value(const ossia::value& old) + { + const auto* text = old.target(); + if(!text) + return old; + std::vector res; + for(bool b : from_text(*text)) + res.push_back(b); + return res; + } + } kinds; + struct : halp::channel_mask<"Channels"> + { + halp_meta(description, "Channels kept") + } channels; + struct notes_list : range_rows<"Notes", true> + { + halp_meta( + description, + "Notes kept, as ranges from one note number to another (60 is C4). No " + "rows: all.") + } notes; + struct ccs_list : range_rows<"CCs", false> + { + halp_meta( + description, + "Controller numbers kept, as ranges from one number to another. No rows: " + "all.") + } ccs; + struct : halp::toggle<"Invert ranges"> + { + halp_meta(description, "Drop what Channels, Notes and CCs match instead of keeping it") + } drop_in_range; + struct : halp::lineedit<"Value range", ""> + { + halp_meta( + description, + "Second data byte tested (velocity, controller value, bend MSB, poly " + "pressure), e.g. 1-63. Empty: all.") + } values; + struct : halp::enum_t + { + halp_meta(description, "Keep values inside or outside the Values list") + } value_test; + struct : halp::value_list<"Channel map", range_map_row, range_maps::max_rows> + { + halp_meta( + description, + "Channels moved from one range onto another, e.g. 1 8 9 16. The first row " + "whose input range holds a value maps it. No rows: none.") + static constexpr std::array columns() + { + return { + halp::list_column{.name = "In from", .min = 1, .max = 16, .init = 1}, + halp::list_column{.name = "In to", .min = 1, .max = 16, .init = 16}, + halp::list_column{.name = "Out from", .min = 1, .max = 16, .init = 1}, + halp::list_column{.name = "Out to", .min = 1, .max = 16, .init = 16}}; + } + } channel_map; + struct : halp::value_list<"Note map", range_map_row, range_maps::max_rows> + { + halp_meta( + description, + "Notes moved from one range onto another, e.g. 1 8 9 16. The first row " + "whose input range holds a value maps it. No rows: none.") + static constexpr std::array columns() + { + return { + halp::list_column{.name = "In from", .min = 0, .max = 127, .init = 0}, + halp::list_column{.name = "In to", .min = 0, .max = 127, .init = 127}, + halp::list_column{.name = "Out from", .min = 0, .max = 127, .init = 0}, + halp::list_column{.name = "Out to", .min = 0, .max = 127, .init = 127}}; + } + } note_map; + struct : halp::value_list<"CC map", range_map_row, range_maps::max_rows> + { + halp_meta( + description, + "Controller numbers moved from one range onto another, e.g. 1 8 9 16. The first row " + "whose input range holds a value maps it. No rows: none.") + static constexpr std::array columns() + { + return { + halp::list_column{.name = "In from", .min = 0, .max = 127, .init = 0}, + halp::list_column{.name = "In to", .min = 0, .max = 127, .init = 127}, + halp::list_column{.name = "Out from", .min = 0, .max = 127, .init = 0}, + halp::list_column{.name = "Out to", .min = 0, .max = 127, .init = 127}}; + } + } cc_map; + struct : halp::enum_t + { + halp_meta(description, "What happens to kept messages outside a map's input range") + } out_of_map; } inputs; struct @@ -88,13 +521,388 @@ struct MidiFilter ossia::variant< ossia::small_vector, ossia::small_vector>> poly_raw; + + struct : halp::midi_bus<"Rejected", libremidi::message> + { + halp_meta(description, "Every message that was not kept") + } rejected; } outputs; + struct ui + { + halp_meta(name, "Midi filter") + halp_meta(layout, halp::layouts::tabs) + halp_meta(background, halp::colors::background_mid) + + struct + { + halp_meta(name, "Basic") + halp_meta(layout, halp::layouts::hbox) + struct + { + halp_meta(layout, halp::layouts::vbox) + halp::item<&ins::filter> filter; + halp::item<&ins::mode> mode; + } kind; + struct + { + halp_meta(layout, halp::layouts::vbox) + struct + { + halp_meta(layout, halp::layouts::hbox) + halp::item<&ins::channel> channel; + halp::item<&ins::index> index; + } numbers; + halp::item<&ins::note_off_to_zero> note_off_to_zero; + halp::item<&ins::zero_to_note_off> zero_to_note_off; + } options; + } basic; + + struct + { + halp_meta(name, "Select") + halp_meta(layout, halp::layouts::hbox) + struct + { + halp_meta(layout, halp::layouts::vbox) + halp::item<&ins::kinds> kinds; + halp::item<&ins::channels> channels; + halp::item<&ins::drop_in_range> drop_in_range; + } left; + struct + { + halp_meta(layout, halp::layouts::vbox) + halp::item<&ins::notes> notes; + halp::item<&ins::ccs> ccs; + halp::item<&ins::values> values; + halp::item<&ins::value_test> value_test; + } right; + } select; + + struct + { + halp_meta(name, "Remap") + halp_meta(layout, halp::layouts::hbox) + struct + { + halp_meta(layout, halp::layouts::vbox) + halp::item<&ins::channel_map> channel_map; + halp::item<&ins::cc_map> cc_map; + } left; + struct + { + halp_meta(layout, halp::layouts::vbox) + halp::item<&ins::note_map> note_map; + halp::item<&ins::out_of_map> out_of_map; + } right; + } remap; + }; + ossia::small_flat_map running; ossia::small_vector out_no_vel; ossia::small_vector out_vel; using tick = halp::tick_musical; + + void stop() noexcept { m_stop = true; } + + void operator()() noexcept + { + update_settings(); + + if(m_stop) + { + m_stop = false; + release_held(0, 0); + running.clear(); + } + + for(const auto& m : inputs.midi) + process(m); + } + +private: + struct held_entry + { + bool to_main{}; //!< where the note-on went + bool base{}; //!< whether it passed the criteria and maps + bool pass{}; //!< whether it also passed the Index control + uint8_t channel{}; //!< 1 - 16, after the maps when base is set + uint8_t note{}; //!< after the maps when base is set + }; + struct held_slot + { + static constexpr int depth = 4; + std::array entries{}; + uint8_t count{}; + }; + + // Settings taken from the controls + uint32_t m_kinds{}; + byte_ranges m_notes, m_ccs; + cached_range_list m_values; + range_maps m_channel_map, m_note_map, m_cc_map; + + //! Indexed by input (channel - 1) * 128 + note: what each held note-on became. + std::array m_held{}; + bool m_stop{}; + + void update_settings() noexcept + { + m_kinds = uint32_t(inputs.kinds.mask()); + m_notes.assign(inputs.notes.value, 0, 127); + m_ccs.assign(inputs.ccs.value, 0, 127); + m_values.update(inputs.values.value, 0, 127); + m_channel_map.assign(inputs.channel_map.value, 1, 16); + m_note_map.assign(inputs.note_map.value, 0, 127); + m_cc_map.assign(inputs.cc_map.value, 0, 127); + } + + static bool is_note_kind(midi_kind k) noexcept + { + return k == midi_kind::note_on || k == midi_kind::note_off + || k == midi_kind::poly_pressure; + } + + void send(bool to_main, const libremidi::message& m) + { + if(to_main) + outputs.midi.push_back(m); + else + outputs.rejected.push_back(m); + } + + void push_held(int channel, int note, held_entry e) noexcept + { + auto& slot = m_held[(channel - 1) * 128 + note]; + if(slot.count == held_slot::depth) + { + for(int i = 0; i + 1 < held_slot::depth; i++) + slot.entries[i] = slot.entries[i + 1]; + slot.count--; + } + slot.entries[slot.count++] = e; + } + + bool pop_held(int channel, int note, held_entry& e) noexcept + { + auto& slot = m_held[(channel - 1) * 128 + note]; + if(slot.count == 0) + return false; + e = slot.entries[0]; + for(int i = 0; i + 1 < slot.count; i++) + slot.entries[i] = slot.entries[i + 1]; + slot.count--; + return true; + } + + //! Sends a note-off for each held note of an input channel (0: all channels). + void release_held(int channel, int64_t ts) + { + for(int c = 1; c <= 16; c++) + { + if(channel != 0 && c != channel) + continue; + for(int n = 0; n < 128; n++) + { + auto& slot = m_held[(c - 1) * 128 + n]; + for(int i = 0; i < slot.count; i++) + { + const auto& e = slot.entries[i]; + auto off = libremidi::channel_events::note_off(c, n, 0); + const bool to_main = off_to_main(e, off); + if(to_main) + off = libremidi::channel_events::note_off(e.channel, e.note, 0); + off.timestamp = ts; + send(to_main, off); + } + slot.count = 0; + } + } + } + + //! A note-off goes where its note-on went, so that a kept note is always + //! ended. When note-offs are selected, those of notes rejected only for + //! their kind go to the main output instead, since that is what was asked. + bool off_to_main(const held_entry& e, const libremidi::message& off) const noexcept + { + return e.to_main || (e.pass && kind_accepts(off, midi_kind::note_off)); + } + + //! Kind selection: the Types, or the Filter type when none is on. + bool kind_accepts(const libremidi::message& m, midi_kind k) const noexcept + { + if(m_kinds != 0) + { + constexpr uint32_t every = (1u << (midi_kind_count - 1)) - 1; + if(k == midi_kind::other) + return m_kinds == every; + return (m_kinds >> int(k)) & 1; + } + + using mt = libremidi::message_type; + const auto t = m.get_message_type(); + const bool zero = m.size() >= 3 && m.bytes[2] == 0; + switch(inputs.filter) + { + case NoteOn: + return t == mt::NOTE_ON && !(inputs.zero_to_note_off && zero); + case NoteOff: + return t == mt::NOTE_OFF + || (t == mt::NOTE_ON && inputs.zero_to_note_off && zero); + case NoteAny: + case NoteRunning: + return t == mt::NOTE_ON || t == mt::NOTE_OFF; + case AfterTouch: + return t == mt::AFTERTOUCH; + case CC: + return t == mt::CONTROL_CHANGE; + case PolyPressure: + return t == mt::POLY_PRESSURE; + case PitchBend: + return t == mt::PITCH_BEND; + } + return false; + } + + //! The Index control applies to notes, poly pressure and controllers. + bool index_accepts(const libremidi::message& m, midi_kind k) const noexcept + { + const bool keyed = is_note_kind(k) || k == midi_kind::control_change; + return !keyed || inputs.index == 0 || (m.size() >= 2 && m.bytes[1] == inputs.index); + } + + //! Channel, range lists and value range. System messages always pass. + bool criteria(const libremidi::message& m, midi_kind k) const noexcept + { + if(!is_channel_message(m)) + return true; + + const int ch = channel_of(m); + if(inputs.channel != 0 && ch != inputs.channel) + return false; + + const bool keyed = is_note_kind(k) || k == midi_kind::control_change; + bool tested = false; + bool inside = true; + // Every channel on tests nothing, as an empty list + if(const uint16_t mask = inputs.channels.mask(); mask != 0xFFFF) + { + tested = true; + inside = inside && ((mask >> (ch - 1)) & 1); + } + if(is_note_kind(k) && !m_notes.empty() && m.size() >= 2) + { + tested = true; + inside = inside && m_notes.contains(m.bytes[1]); + } + if(k == midi_kind::control_change && !m_ccs.empty() && m.size() >= 2) + { + tested = true; + inside = inside && m_ccs.contains(m.bytes[1]); + } + if(tested && inside == bool(inputs.drop_in_range)) + return false; + + const bool has_value = keyed || k == midi_kind::pitch_bend; + if(has_value && !m_values.list.empty() && m.size() >= 3) + { + const bool in = m_values.list.contains(m.bytes[2]); + if(in != (inputs.value_test == Inside)) + return false; + } + return true; + } + + //! Applies the maps. Returns false when the message falls out of a map + //! that drops. + bool remap(libremidi::message& m, midi_kind k) const noexcept + { + if(!is_channel_message(m)) + return true; + const bool drop = inputs.out_of_map == Drop; + + if(m_channel_map.active()) + { + const int ch = channel_of(m); + if(auto r = m_channel_map.find(ch)) + set_channel(m, range_maps::map(*r, ch)); + else if(drop) + return false; + } + if(is_note_kind(k) && m_note_map.active() && m.size() >= 2) + { + if(auto r = m_note_map.find(m.bytes[1])) + m.bytes[1] = uint8_t(range_maps::map(*r, m.bytes[1])); + else if(drop) + return false; + } + if(k == midi_kind::control_change && m_cc_map.active() && m.size() >= 2) + { + if(auto r = m_cc_map.find(m.bytes[1])) + m.bytes[1] = uint8_t(range_maps::map(*r, m.bytes[1])); + else if(drop) + return false; + } + return true; + } + + void process(const libremidi::message& in) + { + if(in.size() == 0) + { + outputs.rejected.push_back(in); + return; + } + + const auto k = classify(in); + if(is_all_notes_off(in)) + release_held(channel_of(in), in.timestamp); + + const bool kind_ok = kind_accepts(in, k); + + libremidi::message out = in; + held_entry tracked; + const bool is_tracked = k == midi_kind::note_off && in.size() >= 3 + && pop_held(channel_of(in), in.bytes[1], tracked); + + // base: the criteria and maps, which also gate the value outputs. + // pass: base and the Index control. + bool base{}, pass{}; + if(is_tracked) + { + base = tracked.base; + pass = tracked.pass; + if(base) + { + set_channel(out, tracked.channel); + out.bytes[1] = tracked.note; + } + } + else + { + base = criteria(in, k) && remap(out, k); + if(!base) + out = in; + pass = base && index_accepts(in, k); + } + + const bool to_main = is_tracked ? off_to_main(tracked, in) : (kind_ok && pass); + if(k == midi_kind::note_on) + { + push_held( + channel_of(in), in.bytes[1], + {to_main, base, pass, uint8_t(channel_of(out)), out.bytes[1]}); + } + + if(base) + extract(in, out); + + send(to_main, to_main ? out : in); + } + + // Value outputs, driven by the Filter type ------------------------------ + void push_poly(int ts) { out_no_vel.clear(); @@ -156,179 +964,166 @@ struct MidiFilter { const int pitch = (int)m.bytes[1]; const int vel = (int)m.bytes[2]; - if(inputs.index == 0 || inputs.index == pitch) + switch(inputs.mode) { - outputs.midi.push_back(m); - switch(inputs.mode) - { - case Both: { - outputs.raw(m.timestamp, note_int{pitch, vel}); - outputs.normalized(m.timestamp, note_float{pitch, vel / 127.f}); - break; - } - case Index: { - outputs.raw(m.timestamp, pitch); - outputs.normalized(m.timestamp, vel / 127.); - break; - } - case Value: { - outputs.raw(m.timestamp, vel); - outputs.normalized(m.timestamp, vel / 127.); - break; - } + case Both: { + outputs.raw(m.timestamp, note_int{pitch, vel}); + outputs.normalized(m.timestamp, note_float{pitch, vel / 127.f}); + break; + } + case Index: { + outputs.raw(m.timestamp, pitch); + outputs.normalized(m.timestamp, vel / 127.); + break; + } + case Value: { + outputs.raw(m.timestamp, vel); + outputs.normalized(m.timestamp, vel / 127.); + break; } } } - void operator()() + //! `in` decides (type, Index), `m` is what is sent out and gives the values. + void extract(const libremidi::message& in, const libremidi::message& m) { - for(auto& m : inputs.midi) - { - if(inputs.channel != 0 && m.get_channel() == inputs.channel) - continue; + using mt = libremidi::message_type; + const auto t = in.get_message_type(); + if(m.size() < 2) + return; + const bool index_ok = inputs.index == 0 || inputs.index == in.bytes[1]; + const bool zero = m.size() >= 3 && m.bytes[2] == 0; - auto add_note_on = [this, &m] { - running[m.bytes[1]] = m.bytes[2]; + auto add_note_on = [&] { + running[m.bytes[1]] = m.bytes[2]; + if(index_ok) output_2_bytes(m); - push_poly(m.timestamp); - }; - auto remove_note_off = [this, &m] { - running.erase(m.bytes[1]); + push_poly(m.timestamp); + }; + auto remove_note_off = [&] { + running.erase(m.bytes[1]); + if(index_ok) output_2_bytes(m); - push_poly(m.timestamp); - }; - auto process_note_on = [&] { - if(inputs.zero_to_note_off && m.bytes[2] == 0) - remove_note_off(); - else - add_note_on(); - }; + push_poly(m.timestamp); + }; + auto process_note_on = [&] { + if(inputs.zero_to_note_off && zero) + remove_note_off(); + else + add_note_on(); + }; - auto process_note_off_running = [&] { - const int pitch = (int)m.bytes[1]; - running.erase(pitch); + // Note-offs only appear in the poly array, and as zero if requested + auto process_note_off_running = [&] { + const int pitch = (int)m.bytes[1]; + running.erase(pitch); - if(inputs.index == 0 || inputs.index == m.bytes[1]) + if(index_ok) + { + if(inputs.note_off_to_zero) { - outputs.midi.push_back(m); - if(inputs.note_off_to_zero) + switch(inputs.mode) { - switch(inputs.mode) - { - case Both: { - outputs.raw(m.timestamp, note_int{pitch, 0}); - outputs.normalized(m.timestamp, note_float{pitch, 0.f}); - break; - } - case Index: { - outputs.raw(m.timestamp, pitch); - outputs.normalized(m.timestamp, 0.); - break; - } - case Value: { - outputs.raw(m.timestamp, 0); - outputs.normalized(m.timestamp, 0.); - break; - } + case Both: { + outputs.raw(m.timestamp, note_int{pitch, 0}); + outputs.normalized(m.timestamp, note_float{pitch, 0.f}); + break; + } + case Index: { + outputs.raw(m.timestamp, pitch); + outputs.normalized(m.timestamp, 0.); + break; + } + case Value: { + outputs.raw(m.timestamp, 0); + outputs.normalized(m.timestamp, 0.); + break; } - push_poly_with_note_offs(m.timestamp, pitch); - } - else - { - push_poly(m.timestamp); } + push_poly_with_note_offs(m.timestamp, pitch); + } + else + { + push_poly(m.timestamp); } - }; + } + }; - const auto t = m.get_message_type(); - switch(inputs.filter) - { - case Type::NoteOn: - if(t == libremidi::message_type::NOTE_ON) + switch(inputs.filter) + { + case Type::NoteOn: + if(t == mt::NOTE_ON && m.size() >= 3) + { + if(inputs.zero_to_note_off && zero) { - if(inputs.zero_to_note_off && m.bytes[2] == 0) - { - running.erase(m.bytes[1]); - push_poly(m.timestamp); - } - else - { - add_note_on(); - } + running.erase(m.bytes[1]); + push_poly(m.timestamp); } - break; - - case Type::NoteOff: - if(t == libremidi::message_type::NOTE_ON) + else { - if(inputs.zero_to_note_off && m.bytes[2] == 0) - remove_note_off(); + add_note_on(); } - else if(t == libremidi::message_type::NOTE_OFF) - remove_note_off(); - break; + } + break; - // In this case we transmit note ons / note offs normally - case Type::NoteAny: - if(t == libremidi::message_type::NOTE_ON) - process_note_on(); - else if(t == libremidi::message_type::NOTE_OFF) - remove_note_off(); + case Type::NoteOff: + if(m.size() < 3) break; + if(t == mt::NOTE_ON) + { + if(inputs.zero_to_note_off && zero) + remove_note_off(); + } + else if(t == mt::NOTE_OFF) + remove_note_off(); + break; - // In this case we want the poly array to inform us about the running notes - // In particular we take note off into account but we don't output them other than at zero if requested. - // They will still be output from the midi out. - case Type::NoteRunning: - if(t == libremidi::message_type::NOTE_ON) - { - if(inputs.zero_to_note_off && m.bytes[2] == 0) - process_note_off_running(); - else - process_note_on(); - } - else if(t == libremidi::message_type::NOTE_OFF) - process_note_off_running(); + case Type::NoteAny: + if(m.size() < 3) break; + if(t == mt::NOTE_ON) + process_note_on(); + else if(t == mt::NOTE_OFF) + remove_note_off(); + break; - case Type::AfterTouch: - if(t == libremidi::message_type::AFTERTOUCH) - { - outputs.midi.push_back(m); - outputs.raw(m.timestamp, m.bytes[1]); - outputs.normalized(m.timestamp, m.bytes[1] / 127.); - } - break; - case Type::CC: - if(t == libremidi::message_type::CONTROL_CHANGE) - { - if(inputs.index == 0 || inputs.index == m.bytes[1]) - { - outputs.midi.push_back(m); - output_2_bytes(m); - } - } - break; - case Type::PolyPressure: - if(t == libremidi::message_type::POLY_PRESSURE) - { - if(inputs.index == 0 || inputs.index == m.bytes[1]) - { - outputs.midi.push_back(m); - output_2_bytes(m); - } - } + case Type::NoteRunning: + if(m.size() < 3) break; - case Type::PitchBend: - if(t == libremidi::message_type::PITCH_BEND) - { - outputs.midi.push_back(m); - const int pb = m.bytes[2] * 128 + m.bytes[1]; - outputs.raw(m.timestamp, pb); - outputs.normalized(m.timestamp, pb / (128. * 128.) - 0.5); - } - break; - } + if(t == mt::NOTE_ON) + { + if(inputs.zero_to_note_off && zero) + process_note_off_running(); + else + process_note_on(); + } + else if(t == mt::NOTE_OFF) + process_note_off_running(); + break; + + case Type::AfterTouch: + if(t == mt::AFTERTOUCH) + { + outputs.raw(m.timestamp, m.bytes[1]); + outputs.normalized(m.timestamp, m.bytes[1] / 127.); + } + break; + case Type::CC: + if(t == mt::CONTROL_CHANGE && index_ok && m.size() >= 3) + output_2_bytes(m); + break; + case Type::PolyPressure: + if(t == mt::POLY_PRESSURE && index_ok && m.size() >= 3) + output_2_bytes(m); + break; + case Type::PitchBend: + if(t == mt::PITCH_BEND && m.size() >= 3) + { + const int pb = m.bytes[2] * 128 + m.bytes[1]; + outputs.raw(m.timestamp, pb); + outputs.normalized(m.timestamp, pb / (128. * 128.) - 0.5); + } + break; } } }; diff --git a/examples/Advanced/MidiScaler/MidiHumanizer.hpp b/examples/Advanced/MidiScaler/MidiHumanizer.hpp index ee1fd4922..bb9ae8c35 100644 --- a/examples/Advanced/MidiScaler/MidiHumanizer.hpp +++ b/examples/Advanced/MidiScaler/MidiHumanizer.hpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -43,17 +44,26 @@ struct MidiHumanizer public: halp_meta(name, "Midi Humanize") halp_meta(c_name, "midi_humanize") - halp_meta(category, "Midi") + halp_meta(category, "Midi/Timing") halp_meta(author, "ossia score") halp_meta(description, "Loosen the timing, velocity and length of MIDI notes") halp_meta(manual_url, "https://ossia.io/score-docs/processes/midi-humanize.html") halp_meta(uuid, "b9830e77-82fd-4d45-8b24-f78e5608eb21") + halp_flag(process_exec); //! Maximum number of events waiting to be released static constexpr int max_pending = 128; //! Maximum number of notes tracked between their note-on and note-off static constexpr int max_active = 64; + //! What one Chance draw covers. + enum ChanceUnit + { + Note, + Chord, + Period + }; + struct ins { halp::midi_bus<"MIDI", libremidi::message> midi; @@ -103,7 +113,7 @@ struct MidiHumanizer } length; // ---- Chance --------------------------------------------------------- - struct : halp::hslider_f32<"Chance", halp::range{0., 1., 1.}> + struct : halp::knob_f32<"Chance", halp::range{0., 1., 1.}> { halp_meta(description, "Probability that a note is played at all") } chance; @@ -150,6 +160,77 @@ struct MidiHumanizer "Standard deviation, in semitones, of a random transposition of each " "note (its note-off follows it)") } pitch; + + // ---- Chance, extended ----------------------------------------------- + struct : halp::enum_t + { + enum widget + { + combobox + }; + halp_meta( + description, + "Note draws Chance for every note. Chord draws once for the notes " + "arriving within the Chord window. Period draws once per period: a " + "failed draw silences every note of that period.") + } chance_unit; + + struct : halp::time_chooser<"Period", halp::range{0.01, 8., 0.5}> + { + halp_meta(description, "Length of a chance period, on the musical grid") + } chance_period; + + struct : halp::toggle<"Vel. chance"> + { + halp_meta( + description, + "The velocity is also a probability: 127 always plays, 64 plays about " + "half of the time") + } velocity_chance; + + // ---- Euclid gate ---------------------------------------------------- + struct : halp::toggle<"Euclid"> + { + halp_meta( + description, "Drops the notes arriving on a rest of a euclidean rhythm") + } euclid; + + struct : halp::spinbox_i32<"Steps", halp::range{1, 64, 16}> + { + halp_meta(description, "Steps in the euclidean rhythm") + } euclid_steps; + + struct : halp::spinbox_i32<"Pulses", halp::range{0, 64, 4}> + { + halp_meta(description, "Hits spread as evenly as possible over the steps") + } euclid_pulses; + + struct : halp::spinbox_i32<"Rotation", halp::range{0, 63, 0}> + { + halp_meta(description, "Turns the rhythm this many steps later") + } euclid_rotation; + + struct : halp::time_chooser<"Step", halp::range{0.01, 2., 0.125}> + { + halp_meta( + description, "Length of a step, on the musical grid: the rhythm starts " + "at the start of the timeline") + } euclid_step; + + // ---- Controllers ---------------------------------------------------- + struct : halp::spinbox_i32<"Random CC", halp::range{-1, 127, -1}> + { + halp_meta( + description, "Controller whose values get a random offset (-1: none)") + } random_cc; + + struct : halp::range_slider_f32< + "CC random range", halp::range_slider_range{-127., 127., {0., 0.}}> + { + halp_meta( + description, "The offset added to that controller is drawn uniformly " + "in this range") + } random_cc_range; } inputs; struct @@ -186,6 +267,10 @@ struct MidiHumanizer } } + //! Called by the host when playback stops; the next tick, which may have no + //! frames, ends every sounding note and drops the queue. + void stop() noexcept { m_stop_requested = true; } + using tick = halp::tick_musical; void operator()(halp::tick_musical t) noexcept { @@ -193,6 +278,20 @@ struct MidiHumanizer cache_parameters(); + if(m_stop_requested) + { + m_stop_requested = false; + flush_sounding(0); + full_reset(); + } + + m_tempo = t.tempo > 0. ? t.tempo : 120.; + m_quarter_start = t.start_position_in_quarters; + m_quarters_per_frame + = (frames > 0 && t.end_position_in_quarters > t.start_position_in_quarters) + ? (t.end_position_in_quarters - t.start_position_in_quarters) / frames + : 0.; + // A rewind restarts a seeded take, so that rendering twice gives the same // result. Free-running (seed 0) just keeps going. Anything already sent out // has to be turned off first, or dropping the queue hangs it. @@ -223,7 +322,7 @@ struct MidiHumanizer struct { halp_meta(name, "Timing") - halp_meta(layout, halp::layouts::vbox) + halp_meta(layout, halp::layouts::section) halp_meta(background, halp::colors::background_mid) halp::item<&ins::timing> timing; @@ -239,14 +338,19 @@ struct MidiHumanizer struct { halp_meta(name, "Notes") - halp_meta(layout, halp::layouts::vbox) + halp_meta(layout, halp::layouts::section) halp_meta(background, halp::colors::background_mid) - halp::item<&ins::velocity> velocity; + struct + { + halp_meta(layout, halp::layouts::grid) + halp_meta(columns, 2) + halp::item<&ins::length> length; + halp::item<&ins::chance> chance; + halp::item<&ins::velocity> velocity; + halp::item<&ins::pitch> pitch; + } amounts; halp::item<&ins::velocity_range> velocity_range; - halp::item<&ins::length> length; - halp::item<&ins::chance> chance; - halp::item<&ins::pitch> pitch; } notes; struct @@ -271,6 +375,55 @@ struct MidiHumanizer halp::item<&ins::key_range> key_range; halp::item<&ins::seed> seed; } scope; + + struct + { + halp_meta(name, "Chance") + halp_meta(layout, halp::layouts::vbox) + halp::item<&ins::chance_unit> chance_unit; + struct + { + halp_meta(layout, halp::layouts::vbox) + static constexpr auto condition() + { + return halp::enabled_when<&ins::chance_unit, Period>{}; + } + halp::item<&ins::chance_period> chance_period; + } period; + halp::item<&ins::velocity_chance> velocity_chance; + } chance; + + struct + { + halp_meta(name, "Gate") + halp_meta(layout, halp::layouts::vbox) + halp::item<&ins::euclid> euclid; + struct + { + halp_meta(layout, halp::layouts::vbox) + static constexpr auto condition() + { + return halp::enabled_when<&ins::euclid, true>{}; + } + struct + { + halp_meta(layout, halp::layouts::grid) + halp_meta(columns, 2) + halp::item<&ins::euclid_steps> euclid_steps; + halp::item<&ins::euclid_pulses> euclid_pulses; + halp::item<&ins::euclid_rotation> euclid_rotation; + halp::item<&ins::euclid_step> euclid_step; + } controls; + } rhythm; + } gate; + + struct + { + halp_meta(name, "CC") + halp_meta(layout, halp::layouts::vbox) + halp::item<&ins::random_cc> random_cc; + halp::item<&ins::random_cc_range> random_cc_range; + } cc; } settings; }; @@ -560,6 +713,14 @@ struct MidiHumanizer outputs.midi.push_back(std::move(msg)); return; } + if(m.size() >= 3 && m_random_cc >= 0 && m.bytes[1] == m_random_cc + && (m_channel == 0 || m.get_channel() == m_channel)) + { + auto msg = m; + msg.bytes[2] = uint8_t(random_cc_value(m.bytes[2])); + schedule(arrival + m_latency, msg); + return; + } break; default: @@ -607,7 +768,8 @@ struct MidiHumanizer // A previous instance of the same note stays tracked: note-offs match the // oldest one first, as MIDI does, and the output stage ends it if this // one starts before its note-off. - const bool dropped = (m_chance < 1.f) && (uniform() > m_chance); + const bool gated = m_euclid && !euclid_hit_at(arrival); + const bool dropped = gated || !chance_passes(m, arrival); active_note note; note.id = m_next_id++; @@ -659,6 +821,80 @@ struct MidiHumanizer schedule(note.on_at, out, note.id); } + //! Position of a date of the current tick on the musical grid, in quarter + //! notes. When the host gives no musical position, the frame clock and the + //! tempo stand in for it. + double quarter_at(int64_t arrival) const noexcept + { + if(m_quarters_per_frame > 0.) + return m_quarter_start + double(arrival - m_now) * m_quarters_per_frame; + return double(arrival) / m_rate * m_tempo / 60.; + } + + //! Index of the grid cell of `length_s` seconds a date falls in. The small + //! bias keeps a note sitting exactly on a boundary in the cell it starts. + int64_t grid_index(int64_t arrival, double length_s) const noexcept + { + const double len_q = length_s * m_tempo / 60.; + if(len_q <= 0.) + return 0; + return int64_t(std::floor(quarter_at(arrival) / len_q + 1e-6)); + } + + bool euclid_hit_at(int64_t arrival) const noexcept + { + const int64_t idx = grid_index(arrival, m_euclid_step_s); + const int64_t step = ((idx % m_euclid_steps) + m_euclid_steps) % m_euclid_steps; + return mtk::euclid_hit(int(step), m_euclid_steps, m_euclid_pulses, m_euclid_rotation); + } + + //! Draws as little as possible: with Chance at 1 and the velocity not used + //! as a probability, nothing is drawn, so the other generators keep their + //! sequence. + bool chance_passes(const libremidi::message& m, int64_t arrival) noexcept + { + const float vel_p = m_velocity_chance ? float(m.bytes[2]) / 127.f : 1.f; + + switch(m_chance_unit) + { + case Note: + default: { + const float p = m_chance * vel_p; + return !((p < 1.f) && (uniform() > p)); + } + + case Chord: { + const bool new_chord + = !(m_chord_open && (arrival - m_chord_anchor) <= m_chord_window); + if(new_chord) + m_chord_draw = (m_chance < 1.f || m_velocity_chance) ? uniform() : 0.f; + return !(m_chord_draw > m_chance * vel_p); + } + + case Period: { + const int64_t idx = grid_index(arrival, m_chance_period_s); + if(!m_period_valid || idx != m_period_index) + { + m_period_valid = true; + m_period_index = idx; + m_period_open = !((m_chance < 1.f) && (uniform() > m_chance)); + } + if(!m_period_open) + return false; + return !((vel_p < 1.f) && (uniform() > vel_p)); + } + } + } + + //! The random offset of the controller selected by Random CC. + int random_cc_value(int value) noexcept + { + double offset = m_cc_lo; + if(m_cc_hi > m_cc_lo) + offset += double(uniform()) * (m_cc_hi - m_cc_lo); + return std::clamp(int(std::lround(double(value) + offset)), 0, 127); + } + void note_off(const libremidi::message& m, int64_t arrival) noexcept { const int pitch = m.bytes[1]; @@ -738,6 +974,21 @@ struct MidiHumanizer const auto [klo, khi] = inputs.key_range.value; m_key_lo = std::clamp(int(std::lround(klo)), 0, 127); m_key_hi = std::clamp(int(std::lround(khi)), m_key_lo, 127); + + m_chance_unit = inputs.chance_unit.value; + m_chance_period_s = std::max(0.001, double(inputs.chance_period.value)); + m_velocity_chance = inputs.velocity_chance.value; + + m_euclid = inputs.euclid.value; + m_euclid_steps = std::clamp(int(inputs.euclid_steps.value), 1, 64); + m_euclid_pulses = std::clamp(int(inputs.euclid_pulses.value), 0, 64); + m_euclid_rotation = std::clamp(int(inputs.euclid_rotation.value), 0, 63); + m_euclid_step_s = std::max(0.001, double(inputs.euclid_step.value)); + + m_random_cc = std::clamp(int(inputs.random_cc.value), -1, 127); + const auto [clo, chi] = inputs.random_cc_range.value; + m_cc_lo = std::clamp(double(clo), -127., 127.); + m_cc_hi = std::clamp(double(chi), m_cc_lo, 127.); } //! Random state only. Safe to call while notes are in flight: it touches @@ -752,11 +1003,17 @@ struct MidiHumanizer m_chord_open = false; m_chord_index = 0; m_chord_offset = 0; + m_chord_draw = 0.f; + m_period_valid = false; if(m_seed != 0) m_rng.seed(uint64_t(m_seed), UINT64_C(0x9e3779b97f4a7c15)); else + { + // Free-running: every reset, e.g. each new play, starts a different take. m_rng.seed(m_free_seed[0], m_free_seed[1]); + m_free_seed[0] += UINT64_C(0x9e3779b97f4a7c15); + } } //! Drops everything, including anything queued. Only safe when nothing can be @@ -856,5 +1113,28 @@ struct MidiHumanizer int m_vel_lo{1}, m_vel_hi{127}; int m_key_lo{0}, m_key_hi{127}; int m_seed{}; + + ChanceUnit m_chance_unit{Note}; + double m_chance_period_s{0.5}; + bool m_velocity_chance{}; + bool m_euclid{}; + int m_euclid_steps{16}; + int m_euclid_pulses{4}; + int m_euclid_rotation{}; + double m_euclid_step_s{0.125}; + int m_random_cc{-1}; + double m_cc_lo{}, m_cc_hi{}; + + //! The musical position of the current tick + double m_tempo{120.}; + double m_quarter_start{}; + double m_quarters_per_frame{}; + + float m_chord_draw{}; + int64_t m_period_index{}; + bool m_period_valid{}; + bool m_period_open{true}; + + bool m_stop_requested{}; }; } diff --git a/examples/Advanced/MidiScaler/MidiLife.hpp b/examples/Advanced/MidiScaler/MidiLife.hpp new file mode 100644 index 000000000..a06e8faf1 --- /dev/null +++ b/examples/Advanced/MidiScaler/MidiLife.hpp @@ -0,0 +1,1523 @@ +#pragma once + +/* SPDX-License-Identifier: GPL-3.0-or-later */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mtk +{ +namespace life +{ +inline constexpr int max_width = 32; +inline constexpr int max_height = 16; + +//! One bit per cell: bit x of rows[y] is column x of row y, row 0 the lowest +//! note. +using grid = std::array; + +//! Neighbour counts that make a cell be born or survive, bit n for n +//! neighbours. +struct rule +{ + uint16_t birth{1 << 3}; + uint16_t survive{(1 << 2) | (1 << 3)}; +}; + +/** + * @brief Reads a Life-like rule: "B3/S23", "b36/s23", "B3S23", "B2/S" or the + * older survive/birth form "23/3". Never allocates. + */ +inline std::optional parse_rule(std::string_view s) noexcept +{ + rule r{0, 0}; + auto digits = [&](std::size_t& i, uint16_t& mask) { + while(i < s.size() && s[i] >= '0' && s[i] <= '8') + mask |= uint16_t(1u << (s[i++] - '0')); + }; + auto skip = [&](std::size_t& i) { + while(i < s.size() && (s[i] == ' ' || s[i] == '/' || s[i] == ',' || s[i] == '\t')) + i++; + }; + + std::size_t i = 0; + skip(i); + if(i == s.size()) + return std::nullopt; + + if(s[i] >= '0' && s[i] <= '8') + { + // S/B: survive digits, a slash, birth digits + digits(i, r.survive); + if(i >= s.size() || s[i] != '/') + return std::nullopt; + i++; + digits(i, r.birth); + skip(i); + return i == s.size() ? std::optional{r} : std::nullopt; + } + + bool seen_b = false, seen_s = false; + while(i < s.size()) + { + const char c = s[i++]; + if((c == 'B' || c == 'b') && !seen_b) + { + seen_b = true; + digits(i, r.birth); + } + else if((c == 'S' || c == 's') && !seen_s) + { + seen_s = true; + digits(i, r.survive); + } + else + return std::nullopt; + skip(i); + } + if(!seen_b || !seen_s) + return std::nullopt; + return r; +} + +struct pattern_cells +{ + grid rows{}; //!< row 0 is the first line of the text, the top one + int width{}; + int height{}; +}; + +/** + * @brief Reads a drawn pattern: lines separated by '/', spaces or new lines, + * '.', '0', '_' or '-' for a dead cell, any other character for a live one. + * The first line is the top row. Lines beyond the grid's capacity are cut. + */ +inline pattern_cells parse_pattern(std::string_view s) noexcept +{ + pattern_cells p; + int x = 0; + bool in_line = false; + for(char c : s) + { + if(c == '/' || c == ' ' || c == '\n' || c == '\r' || c == '\t' || c == ',') + { + if(in_line) + { + p.height++; + in_line = false; + x = 0; + } + continue; + } + if(p.height >= max_height) + break; + in_line = true; + const bool dead = c == '.' || c == '0' || c == '_' || c == '-'; + if(x < max_width) + { + if(!dead) + p.rows[p.height] |= 1u << x; + p.width = std::max(p.width, x + 1); + } + x++; + } + if(in_line) + p.height++; + p.height = std::min(p.height, max_height); + return p; +} + +//! Keeps a parsed value in sync with a text control, parsing again only when +//! the text changed; the last text is kept in a fixed buffer. +template +struct cached_text +{ + static constexpr std::size_t max_text = 256; + T parsed{}; + + template + bool update(std::string_view text, Parse&& parse) noexcept + { + if(text.size() > max_text) + text = text.substr(0, max_text); + if(m_parsed && text == std::string_view{m_text.data(), m_size}) + return false; + std::copy(text.begin(), text.end(), m_text.begin()); + m_size = text.size(); + m_parsed = true; + parse(text, parsed); + return true; + } + +private: + std::array m_text{}; + std::size_t m_size{}; + bool m_parsed{}; +}; + +//! Scales of the rows, in semitones above the root, and their size. +struct scale_steps +{ + std::array steps; + int8_t count; +}; + +inline constexpr scale_steps scales[] = { + {{0, 2, 4, 5, 7, 9, 11}, 7}, // Major + {{0, 2, 3, 5, 7, 9, 10}, 7}, // Dorian + {{0, 1, 3, 5, 7, 8, 10}, 7}, // Phrygian + {{0, 2, 4, 6, 7, 9, 11}, 7}, // Lydian + {{0, 2, 4, 5, 7, 9, 10}, 7}, // Mixolydian + {{0, 2, 3, 5, 7, 8, 10}, 7}, // Minor + {{0, 1, 3, 5, 6, 8, 10}, 7}, // Locrian + {{0, 2, 3, 5, 7, 8, 11}, 7}, // Harmonic minor + {{0, 2, 3, 5, 7, 9, 11}, 7}, // Melodic minor + {{0, 2, 4, 7, 9}, 5}, // Major pentatonic + {{0, 3, 5, 7, 10}, 5}, // Minor pentatonic + {{0, 3, 5, 6, 7, 10}, 6}, // Blues + {{0, 2, 4, 6, 8, 10}, 6}, // Whole tone + {{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11}, 12}, // Chromatic +}; +inline constexpr int scale_count = int(std::size(scales)); + +inline constexpr int scale_semitones(int scale, int degree) noexcept +{ + const auto& s = scales[std::clamp(scale, 0, scale_count - 1)]; + const int octave = degree / s.count; + return octave * 12 + s.steps[degree % s.count]; +} + +//! The state the layer paints, sent from the processing thread at most once +//! per tick. Trivial, so the message bus copies it as bytes. +struct view_message +{ + grid rows; + uint32_t generation; + uint16_t population; + uint8_t width; + uint8_t height; + int8_t column; //!< the play-head, -1 before the first step + uint8_t running; +}; + +//! A click on a cell of the layer, in grid coordinates (row 0 at the bottom). +struct toggle_message +{ + uint8_t x; + uint8_t y; +}; + +inline bool cell(const grid& g, int x, int y) noexcept +{ + return (g[y] >> x) & 1u; +} + +/** + * @brief The layer's view of the world: live cells, the cells born in the + * current generation, the play-head column. + * + * Births are what is alive now and was not in the generation before, so the + * view keeps the previous generation it saw; a generation 0 is all births. + */ +struct life_view +{ + // The host gives custom items a fixed size: 2:1 like the largest grid + static constexpr double width() { return 288.; } + static constexpr double height() { return 144.; } + + view_message state{}; + grid previous{}; + bool has_state{}; + bool previous_valid{}; + + std::function update; + std::function on_toggle; + + void receive(const view_message& m) noexcept + { + if(!has_state || m.generation != state.generation) + { + previous_valid = has_state && m.generation == state.generation + 1; + previous = state.rows; + } + state = m; + has_state = true; + if(update) + update(); + } + + bool born(int x, int y) const noexcept + { + if(!cell(state.rows, x, y)) + return false; + if(state.generation == 0) + return true; + return previous_valid && !cell(previous, x, y); + } + + //! Cells fill the whole view: square for a 2:1 grid, stretched otherwise. + double cell_width() const noexcept { return width() / std::max(1, state.width); } + double cell_height() const noexcept { return height() / std::max(1, state.height); } + + void paint(auto ctx) + { + ctx.set_fill_color({24, 24, 26, 255}); + ctx.begin_path(); + ctx.draw_rect(0., 0., width(), height()); + ctx.fill(); + + if(!has_state || state.width == 0 || state.height == 0) + { + ctx.set_fill_color({140, 140, 140, 255}); + ctx.begin_path(); + ctx.draw_text(10., height() / 2. - 4., "Play to see the world."); + ctx.draw_text(10., height() / 2. + 12., "Click a cell to toggle it."); + ctx.fill(); + return; + } + + const int w = state.width, h = state.height; + const double cw = cell_width(), ch = cell_height(); + const double gap = std::min(cw, ch) >= 6. ? 1. : 0.; + + for(int y = 0; y < h; y++) + { + const double py = (h - 1 - y) * ch; + for(int x = 0; x < w; x++) + { + const bool alive = cell(state.rows, x, y); + const bool head = x == state.column; + if(!alive) + { + if(head) + ctx.set_fill_color({74, 64, 34, 255}); + else + ctx.set_fill_color({40, 40, 44, 255}); + } + else if(born(x, y)) + { + if(head) + ctx.set_fill_color({255, 250, 210, 255}); + else + ctx.set_fill_color({120, 220, 255, 255}); + } + else + { + if(head) + ctx.set_fill_color({255, 214, 90, 255}); + else + ctx.set_fill_color({255, 176, 30, 255}); + } + ctx.begin_path(); + ctx.draw_rect(x * cw + gap, py + gap, cw - gap, ch - gap); + ctx.fill(); + } + } + } + + bool mouse_press(double px, double py) + { + // Only the running world takes clicks: there is no other to send them to + if(!has_state || !state.running || !on_toggle || state.width == 0 + || state.height == 0) + return false; + const int x = int(px / cell_width()); + const int row = int(py / cell_height()); + if(px < 0. || py < 0. || x >= state.width || row >= state.height) + return false; + const int y = state.height - 1 - row; + state.rows[y] ^= 1u << x; + on_toggle({uint8_t(x), uint8_t(y)}); + return true; + } + void mouse_move(double, double) { } + void mouse_release(double, double) { } +}; +} + +/** + * @brief A Game of Life plays notes: a play-head sweeps the grid column by + * column on the musical grid, and the live cells of its column play the notes + * of their rows. + * + * Rows are scale degrees from the root (row 0, the bottom one), or the notes + * of a note map for drums. The world evolves one generation per sweep of the + * play-head, or every N steps, so a phrase repeats before it changes. + * With Births, only the cells born in the current generation play. + * + * Velocity is the base velocity plus Crowd times (neighbours - 2): crowded + * cells play louder, isolated ones softer, or the reverse with a negative + * Crowd. When a column holds more live cells than Max notes, the ones played + * are spread evenly over it. + * + * Auto reseed starts a new world when this one dies out, or after it has + * repeated a recent generation (still lifes, oscillators of period up to 16) + * for Reseed after generations. A random world comes from Seed (0: a new one + * each time); a pattern comes back centred, then at seeded random places. + * + * Synced, steps land on the musical grid of the timeline; free, they are + * Rate seconds apart from the start of playback. Stop, seeks and loops end + * every note and restart the world from its seed. Clicking a cell of the layer + * toggles it in the running world. Incoming MIDI passes through; CC 120 and + * 123 also end the notes that sound. + */ +struct MidiLife +{ + halp_meta(name, "Midi life") + halp_meta(c_name, "midi_life") + halp_meta(category, "Midi/Generators") + halp_meta(author, "ossia score") + halp_meta( + description, "A Game of Life sequencer: a play-head sweeps the evolving grid and " + "live cells play the notes of their rows") + halp_meta(manual_url, "https://ossia.io/score-docs/processes/midi-life.html") + halp_meta(uuid, "04db97fd-2ccf-4271-a5a6-d66552baf0d7") + halp_flag(process_exec); + + enum Evolve + { + EachSweep, + EveryNSteps + }; + enum RuleChoice + { + RuleLife, + RuleHighLife, + RuleSeeds, + RuleDayNight, + RuleMaze, + RuleMove, + RuleCustom + }; + enum Start + { + StartRandom, + StartText, + StartGlider, + StartBlinker, + StartToad, + StartRPentomino, + StartAcorn, + StartDiehard, + StartSpaceship, + StartPentadecathlon, + StartPulsar + }; + enum Play + { + PlayLive, + PlayBirths + }; + + static constexpr int max_steps_per_tick = 256; + + struct ins + { + halp::midi_bus<"MIDI", libremidi::message> midi; + + struct : halp::time_chooser<"Rate", halp::range{0.01, 4., 0.125}> + { + halp_meta( + description, "Time between two steps of the play-head; synced, a note value " + "on the bars' grid") + } rate; + struct : halp::knob_f32<"Gate", halp::range{0.05, 1., 0.5}> + { + halp_meta(description, "Length of a note, as a proportion of a step") + } gate; + struct : halp::knob_f32<"Swing", halp::range{0., 1., 0.}> + { + halp_meta( + description, "Delays every second step of the grid: 0.5 puts it on the " + "triplet, 1 at 5/6 of the pair") + } swing; + struct + { + halp_meta(name, "Evolve"); + halp_meta( + description, "When the world moves to its next generation: each time the " + "play-head starts a sweep, or every few steps"); + enum widget + { + combobox + }; + struct range + { + halp::combo_pair values[2]{{"Each sweep", EachSweep}, {"Every N steps", EveryNSteps}}; + int init{EachSweep}; + }; + int value{EachSweep}; + } evolve; + struct : halp::spinbox_i32<"Steps/gen", halp::irange{1, 64, 4}> + { + halp_meta(description, "Steps of the play-head between two generations") + } steps_per_gen; + + struct : halp::spinbox_i32<"Width", halp::irange{4, life::max_width, 16}> + { + halp_meta(description, "Columns of the grid: steps in a sweep") + } width; + struct : halp::spinbox_i32<"Height", halp::irange{2, life::max_height, 8}> + { + halp_meta(description, "Rows of the grid: one note each") + } height; + struct : halp::toggle<"Wrap", halp::toggle_setup{.init = true}> + { + halp_meta(description, "The edges touch the opposite ones, as on a torus") + } wrap; + struct + { + halp_meta(name, "Rule"); + halp_meta( + description, "How cells are born and survive. Life B3/S23, HighLife B36/S23 " + "(replicators), Seeds B2/S (explosive), Day and Night " + "B3678/S34678, Maze B3/S12345 (fills up), Move B368/S245"); + enum widget + { + combobox + }; + struct range + { + halp::combo_pair values[7]{ + {"Life", RuleLife}, {"HighLife", RuleHighLife}, {"Seeds", RuleSeeds}, + {"Day and Night", RuleDayNight}, {"Maze", RuleMaze}, {"Move", RuleMove}, + {"Custom", RuleCustom}}; + int init{RuleLife}; + }; + int value{RuleLife}; + } rule; + struct : halp::lineedit<"Custom rule", "B3/S23"> + { + halp_meta( + description, "A Life-like rule: B then the neighbour counts that give birth, " + "S then those that keep a cell alive, e.g. B36/S23") + } custom_rule; + + struct + { + halp_meta(name, "Start"); + halp_meta( + description, "The first world: a random fill, the drawn pattern, or a " + "classic pattern in the centre"); + enum widget + { + combobox + }; + struct range + { + halp::combo_pair values[11]{ + {"Random", StartRandom}, + {"Pattern", StartText}, + {"Glider", StartGlider}, + {"Blinker", StartBlinker}, + {"Toad", StartToad}, + {"R-pentomino", StartRPentomino}, + {"Acorn", StartAcorn}, + {"Diehard", StartDiehard}, + {"Spaceship", StartSpaceship}, + {"Pentadecathlon", StartPentadecathlon}, + {"Pulsar", StartPulsar}}; + int init{StartRandom}; + }; + int value{StartRandom}; + } start; + struct : halp::lineedit<"Pattern", ".o./..o/ooo"> + { + halp_meta( + description, "Rows from the top, separated by '/' or spaces: '.' a dead " + "cell, 'o' or '#' a live one") + } pattern; + struct : halp::knob_f32<"Density", halp::range{0.01, 1., 0.3}> + { + halp_meta(description, "Proportion of live cells in a random world") + } density; + struct : halp::spinbox_i32<"Seed", halp::irange{0, 9999, 0}> + { + halp_meta( + description, "The same seed gives the same worlds on every play; 0 gives " + "new ones each time") + } seed; + struct : halp::impulse_button<"Reseed"> + { + halp_meta(description, "Start a new world at the next step") + } reseed; + struct : halp::toggle<"Auto reseed", halp::toggle_setup{.init = true}> + { + halp_meta( + description, "Start a new world when this one dies out or keeps repeating") + } auto_reseed; + struct : halp::spinbox_i32<"Reseed after", halp::irange{1, 64, 4}> + { + halp_meta( + description, "Generations a repeating world plays before a new one starts") + } patience; + + struct + { + halp_meta(name, "Play"); + halp_meta( + description, "Every live cell under the play-head plays, or only those born " + "in this generation"); + enum widget + { + combobox + }; + struct range + { + halp::combo_pair values[2]{{"Live cells", PlayLive}, {"Births", PlayBirths}}; + int init{PlayLive}; + }; + int value{PlayLive}; + } play; + struct + { + halp_meta(name, "Root"); + halp_meta(description, "Note of the bottom row"); + enum widget + { + combobox + }; + struct range + { + halp::combo_pair values[12]{ + {"C", 0}, {"C#", 1}, {"D", 2}, {"D#", 3}, {"E", 4}, {"F", 5}, + {"F#", 6}, {"G", 7}, {"G#", 8}, {"A", 9}, {"A#", 10}, {"B", 11}}; + int init{0}; + }; + int value{0}; + } root; + struct : halp::spinbox_i32<"Octave", halp::irange{-1, 8, 3}> + { + halp_meta(description, "Octave of the bottom row; C3 is MIDI note 48") + } octave; + struct + { + halp_meta(name, "Scale"); + halp_meta(description, "Scale the rows climb, one degree per row"); + enum widget + { + combobox + }; + struct range + { + halp::combo_pair values[life::scale_count]{ + {"Major", 0}, {"Dorian", 1}, {"Phrygian", 2}, + {"Lydian", 3}, {"Mixolydian", 4}, {"Minor", 5}, + {"Locrian", 6}, {"Harmonic minor", 7}, {"Melodic minor", 8}, + {"Major penta", 9}, {"Minor penta", 10}, {"Blues", 11}, + {"Whole tone", 12}, {"Chromatic", 13}}; + int init{9}; + }; + int value{9}; + } mode; + struct : halp::value_list<"Note map", int, life::max_height> + { + halp_meta( + description, "Notes of the rows from the bottom, e.g. 36 38 42 46 for drums; " + "rows past the list are silent. Empty: the scale.") + static constexpr std::array columns() + { + return {halp::list_column{.name = "Note", .min = 0, .max = 127, .init = 36}}; + } + } note_map; + struct : halp::spinbox_i32<"Channel", halp::irange{1, 16, 1}> + { + halp_meta(description, "Channel of the notes") + } channel; + struct : halp::hslider_i32<"Velocity", halp::irange{1, 127, 90}> + { + halp_meta(description, "Velocity of a cell with two neighbours") + } velocity; + struct : halp::hslider_i32<"Crowd", halp::irange{-30, 30, 10}> + { + halp_meta( + description, "Velocity added per neighbour above two, removed per neighbour " + "below: crowded cells louder, or softer when negative") + } crowd; + struct : halp::spinbox_i32<"Max notes", halp::irange{1, life::max_height, 4}> + { + halp_meta(description, "Most notes a step plays, spread over its live cells") + } max_notes; + } inputs; + + struct + { + halp::midi_bus<"MIDI", libremidi::message> midi; + struct : halp::val_port<"Population", int> + { + halp_meta(description, "Live cells in the grid") + } population; + struct : halp::val_port<"Generation", int> + { + halp_meta(description, "Generations since the world started") + } generation; + } outputs; + + std::function send_message; + + void process_message(const life::toggle_message& m) noexcept + { + if(m.x >= life::max_width || m.y >= life::max_height) + return; + m_cells[m.y] ^= 1u << m.x; + m_population = count_cells(m_cells); + m_view_dirty = true; + } + + struct ui + { + halp_meta(name, "Midi life") + halp_meta(layout, halp::layouts::hbox) + halp_meta(background, halp::colors::background_dark) + + struct + { + halp_meta(name, "Settings") + halp_meta(layout, halp::layouts::tabs) + halp_meta(background, halp::colors::background_darker) + + struct + { + halp_meta(name, "Time") + halp_meta(layout, halp::layouts::vbox) + struct + { + halp_meta(layout, halp::layouts::hbox) + halp::item<&ins::rate> rate; + halp::item<&ins::gate> gate; + halp::item<&ins::swing> swing; + } clock; + struct + { + halp_meta(layout, halp::layouts::hbox) + halp::item<&ins::evolve> evolve; + struct + { + halp_meta(layout, halp::layouts::vbox) + static constexpr auto condition() + { + return halp::enabled_when<&ins::evolve, EveryNSteps>{}; + } + halp::item<&ins::steps_per_gen> steps_per_gen; + } steps_per_gen; + } generations; + } time; + + struct + { + halp_meta(name, "World") + halp_meta(layout, halp::layouts::vbox) + struct + { + halp_meta(layout, halp::layouts::hbox) + // Not named width and height: the layout reads those as its size + halp::item<&ins::width> columns; + halp::item<&ins::height> rows; + halp::item<&ins::wrap> wrap; + } size; + halp::item<&ins::rule> rule; + struct + { + halp_meta(layout, halp::layouts::vbox) + static constexpr auto condition() + { + return halp::enabled_when<&ins::rule, RuleCustom>{}; + } + halp::item<&ins::custom_rule> custom_rule; + } custom_rule; + } world; + + struct + { + halp_meta(name, "Seed") + halp_meta(layout, halp::layouts::vbox) + struct + { + halp_meta(layout, halp::layouts::hbox) + halp::item<&ins::start> start; + struct + { + halp_meta(layout, halp::layouts::vbox) + static constexpr auto condition() + { + return halp::enabled_when<&ins::start, StartRandom>{}; + } + halp::item<&ins::density> density; + } density; + halp::item<&ins::seed> seed; + } first; + struct + { + halp_meta(layout, halp::layouts::vbox) + static constexpr auto condition() + { + return halp::enabled_when<&ins::start, StartText>{}; + } + halp::item<&ins::pattern> pattern; + } pattern; + struct + { + halp_meta(layout, halp::layouts::hbox) + halp::item<&ins::reseed> reseed; + halp::item<&ins::auto_reseed> auto_reseed; + struct + { + halp_meta(layout, halp::layouts::vbox) + static constexpr auto condition() + { + return halp::enabled_when<&ins::auto_reseed, true>{}; + } + halp::item<&ins::patience> patience; + } patience; + } again; + } seeding; + + struct + { + halp_meta(name, "Notes") + halp_meta(layout, halp::layouts::vbox) + struct + { + halp_meta(layout, halp::layouts::hbox) + halp::item<&ins::play> play; + halp::item<&ins::root> root; + halp::item<&ins::octave> octave; + } pitch; + struct + { + halp_meta(layout, halp::layouts::hbox) + halp::item<&ins::mode> mode; + halp::item<&ins::channel> channel; + } key; + halp::item<&ins::note_map> note_map; + } notes; + + struct + { + halp_meta(name, "Dynamics") + halp_meta(layout, halp::layouts::hbox) + halp::item<&ins::velocity> velocity; + halp::item<&ins::crowd> crowd; + halp::item<&ins::max_notes> max_notes; + } dynamics; + } settings; + + struct + { + halp_meta(name, "Grid") + halp_meta(layout, halp::layouts::section) + halp_meta(background, halp::colors::background_mid) + halp::custom_actions_item view; + } grid; + + struct bus + { + std::function send_message; + + void init(ui& self) + { + self.grid.view.on_toggle = [this](life::toggle_message m) { + if(send_message) + send_message(m); + }; + } + + static void process_message(ui& self, life::view_message msg) + { + self.grid.view.receive(msg); + } + }; + }; + + void prepare(halp::setup s) noexcept + { + if(s.rate > 0.) + m_rate = s.rate; + } + + void stop() noexcept { m_stop_requested = true; } + + //! The host moved the transport: the next tick starts the take over. + template + void transport(std::chrono::duration) noexcept + { + m_seek_requested = true; + } + + MidiLife() noexcept + { + m_free_rng = uint64_t(std::chrono::steady_clock::now().time_since_epoch().count()) + ^ uint64_t(reinterpret_cast(this)); + } + + using tick = halp::tick_musical; + void operator()(const halp::tick_musical& t) noexcept + { + m_frames = t.frames > 0 ? t.frames : 0; + cache_parameters(); + + // position_in_frames only ever grows: a seek is told by transport(), or + // seen as the musical position going back + const bool sought = std::exchange(m_seek_requested, false); + const bool went_back = m_has_last && m_frames > 0 + && t.start_position_in_quarters < m_last_end_quarter - 1e-6; + if(m_stop_requested) + { + m_stop_requested = false; + end_all(0, 0); + m_queue.clear(); + restart(); + m_view_dirty = true; + m_running = false; + m_has_last = false; + } + else if(m_has_last && (sought || went_back)) + { + end_all(0, 0); + m_queue.clear(); + restart(); + } + + if(!m_ready) + restart(); + + if(inputs.reseed.value) + m_reseed_requested = true; + + if(m_frames > 0) + { + m_has_last = true; + m_running = true; + m_last_end_quarter = t.end_position_in_quarters; + run_steps(t); + } + + for(const auto& m : inputs.midi) + { + const int64_t ts + = m_frames > 0 ? std::clamp(m.timestamp, 0, m_frames - 1) : 0; + m_queue.release(m_now, int(ts), [this](libremidi::message&& e) { send(std::move(e)); }); + handle(m, ts); + } + m_queue.release(m_now, m_frames, [this](libremidi::message&& e) { send(std::move(e)); }); + + outputs.population.value = m_population; + outputs.generation.value = int(m_generation); + + if(send_message && (m_view_dirty || ++m_view_idle >= 16)) + { + m_view_dirty = false; + m_view_idle = 0; + send_message(view()); + } + m_now += m_frames; + } + + // Observers, for tests and the layer + const life::grid& cells() const noexcept { return m_cells; } + uint32_t generation() const noexcept { return m_generation; } + int population() const noexcept { return m_population; } + int column() const noexcept { return m_column; } + int reseeds() const noexcept { return m_reseeds; } + int sounding() const noexcept + { + int n = 0; + for(auto& c : m_sounding) + for(auto v : c) + n += v; + return n; + } + + life::view_message view() const noexcept + { + life::view_message m; + m.rows = m_cells; + m.generation = m_generation; + m.population = uint16_t(m_population); + m.width = uint8_t(m_w); + m.height = uint8_t(m_h); + m.column = int8_t(m_column); + m.running = m_running; + return m; + } + + //! One generation of `g` on a `w` x `h` grid. + static life::grid + next_generation(const life::grid& g, int w, int h, bool wrap, life::rule r) noexcept + { + life::grid out{}; + for(int y = 0; y < h; y++) + for(int x = 0; x < w; x++) + { + const int n = neighbours(g, x, y, w, h, wrap); + const bool alive = life::cell(g, x, y); + if(alive ? (r.survive >> n) & 1 : (r.birth >> n) & 1) + out[y] |= 1u << x; + } + return out; + } + + static int + neighbours(const life::grid& g, int x, int y, int w, int h, bool wrap) noexcept + { + int n = 0; + for(int dy = -1; dy <= 1; dy++) + for(int dx = -1; dx <= 1; dx++) + { + if(dx == 0 && dy == 0) + continue; + int nx = x + dx, ny = y + dy; + if(wrap) + { + nx = (nx + w) % w; + ny = (ny + h) % h; + } + else if(nx < 0 || ny < 0 || nx >= w || ny >= h) + continue; + n += life::cell(g, nx, ny); + } + return n; + } + +private: + // ------------------------------------------------------------- parameters + + void cache_parameters() noexcept + { + m_w = std::clamp(inputs.width.value, 4, life::max_width); + m_h = std::clamp(inputs.height.value, 2, life::max_height); + m_wrap = inputs.wrap.value; + + m_custom_rule.update(inputs.custom_rule.value, [](std::string_view s, auto& r) { + r = life::parse_rule(s); + }); + switch(inputs.rule.value) + { + default: + case RuleLife: + m_rule = {1 << 3, (1 << 2) | (1 << 3)}; + break; + case RuleHighLife: + m_rule = {(1 << 3) | (1 << 6), (1 << 2) | (1 << 3)}; + break; + case RuleSeeds: + m_rule = {1 << 2, 0}; + break; + case RuleDayNight: + m_rule = {(1 << 3) | (1 << 6) | (1 << 7) | (1 << 8), + (1 << 3) | (1 << 4) | (1 << 6) | (1 << 7) | (1 << 8)}; + break; + case RuleMaze: + m_rule = {1 << 3, (1 << 1) | (1 << 2) | (1 << 3) | (1 << 4) | (1 << 5)}; + break; + case RuleMove: + m_rule = {(1 << 3) | (1 << 6) | (1 << 8), (1 << 2) | (1 << 4) | (1 << 5)}; + break; + case RuleCustom: + m_rule = m_custom_rule.parsed ? *m_custom_rule.parsed : life::rule{}; + break; + } + + m_pattern.update(inputs.pattern.value, [](std::string_view s, auto& p) { + p = life::parse_pattern(s); + }); + } + + // ------------------------------------------------------------------ steps + + //! The step length as a note value, snapped to the note values a time + //! chooser offers so that float rounding does not move the grid. + double synced_rate(const halp::tick_musical& t) const noexcept + { + const double tempo = t.tempo > 0. ? t.tempo : 120.; + const double whole = double(inputs.rate.value) * tempo / 240.; + if(!(whole > 0.)) + return 0.; + const double ticks = std::round(whole * 6720.); + const double snapped = ticks > 0. && std::abs(ticks - whole * 6720.) < 0.01 * ticks + ? ticks / 6720. + : whole; + return 1. / snapped; + } + + void run_steps(const halp::tick_musical& t) noexcept + { + const double dq = t.end_position_in_quarters - t.start_position_in_quarters; + const bool synced = inputs.rate.sync && dq > 0.; + if(synced) + { + const double rate = synced_rate(t); + if(!(rate > 0.)) + return; + const double unit_q = rate <= 1. ? quarters_in_bar(t) / rate : 4. / rate; + m_step_frames = unit_q * m_frames / dq; + for_each_grid_point( + t, rate, [&](int frame, int64_t index) { step(m_now + frame, index); }); + // Free steps start again from wherever the synced ones leave off + m_free_next = -1.; + } + else + { + if(dq < 0.) + return; + const double len = std::max(1., double(inputs.rate.value) * m_rate); + m_step_frames = len; + const double end = double(m_now + m_frames); + if(m_free_next < double(m_now)) + m_free_next = double(m_now); + int guard = 0; + while(m_free_next < end && guard++ < max_steps_per_tick) + { + step(int64_t(std::floor(m_free_next)), m_free_index++); + m_free_next += len; + } + // A step too short for the tick drops the excess instead of piling up + if(m_free_next < end) + m_free_next = end; + } + } + + static double quarters_in_bar(const halp::tick_musical& t) noexcept + { + return (t.signature.num > 0 && t.signature.denom > 0) + ? 4. * t.signature.num / t.signature.denom + : 4.; + } + + //! The points of halp::tick_musical::get_quantification_date for forward + //! playback, without allocating: subdivisions of the quarter restart at + //! each bar line, a bar or longer counts from the last signature change. + //! `f(frame, index)` gets the point's index on that grid, for the swing. + template + static void + for_each_grid_point(const halp::tick_musical& t, double rate, F&& f) noexcept + { + const double start = t.start_position_in_quarters; + const double end = t.end_position_in_quarters; + if(!(end > start) || t.frames <= 0 || !(rate > 0.)) + return; + constexpr double eps = 1e-9; + int count = 0; + auto push = [&](double p, int64_t index) { + if(p >= end || count >= max_steps_per_tick) + return false; + const int64_t fr = std::clamp( + halp::musical_to_frame(p, start, end, t.frames), int64_t(0), + int64_t(t.frames - 1)); + f(int(fr), index); + count++; + return true; + }; + + if(rate <= 1.) + { + const double unit = quarters_in_bar(t) / rate; + const double origin = t.last_signature_change; + const int64_t first = int64_t(std::ceil((start - origin) / unit - eps)); + for(int64_t k = first; k * unit + origin < end - eps; k++) + if(!push(k * unit + origin, k)) + break; + return; + } + + const double unit = 4. / rate; + bool go = true; + t.for_each_bar_segment(start, end, [&](double bar_line, double next_bar) { + if(!go) + return; + for(int k = 0;; k++) + { + const double p = bar_line + k * unit; + if(p >= next_bar - eps || p > end + eps) + return; + if(p < start) + continue; + if(!push(p, k)) + { + go = false; + return; + } + } + }); + } + + //! A step of the play-head at the absolute frame `at`, `grid_index` on the + //! grid of steps: odd ones are swung. + void step(int64_t at, int64_t grid_index) noexcept + { + const int w = m_w; + const int64_t s = m_step++; + const int col = int(s % w); + + if(m_reseed_requested) + { + m_reseed_requested = false; + reseed(); + } + else if(s > 0) + { + const bool due = inputs.evolve.value == EachSweep + ? col == 0 + : (s % std::max(1, inputs.steps_per_gen.value)) == 0; + if(due) + evolve(); + } + m_column = col; + m_view_dirty = true; + + // The cells that play, bottom to top + int ys[life::max_height]; + int n = 0; + const bool births = inputs.play.value == PlayBirths; + for(int y = 0; y < m_h; y++) + { + if(!life::cell(m_cells, col, y)) + continue; + if(births && m_generation > 0 && life::cell(m_previous, col, y)) + continue; + ys[n++] = y; + } + if(n == 0) + return; + + const int max = std::clamp(inputs.max_notes.value, 1, life::max_height); + const int played = std::min(n, max); + + const int64_t delay = std::llround( + swing_delay(grid_index, double(inputs.swing.value)) * m_step_frames); + const int64_t on_at = at + std::max(0, delay); + const int64_t len = std::max( + 1, std::llround(m_step_frames * std::clamp(double(inputs.gate.value), 0.01, 1.))); + const int ch = std::clamp(inputs.channel.value, 1, 16); + + for(int k = 0; k < played; k++) + { + // Spread evenly: the middle cell of each of `played` equal slices + const int idx = played == n ? k : int((2 * k + 1) * n / (2 * played)); + const int y = ys[idx]; + const int pitch = pitch_of(y); + if(pitch < 0) + continue; + const int nb = neighbours(m_cells, col, y, w, m_h, m_wrap); + const int vel = std::clamp( + inputs.velocity.value + inputs.crowd.value * (nb - 2), 1, 127); + m_queue.schedule(on_at, libremidi::channel_events::note_on(ch, pitch, vel)); + m_queue.schedule(on_at + len, libremidi::channel_events::note_off(ch, pitch, 0)); + } + } + + //! The note of a row, or -1 when the row is silent. + int pitch_of(int y) const noexcept + { + const auto& map = inputs.note_map.value; + if(!map.empty()) + return y < int(map.size()) ? std::clamp(map[y], 0, 127) : -1; + const int lowest = (std::clamp(inputs.octave.value, -1, 9) + 1) * 12 + + std::clamp(inputs.root.value, 0, 11); + const int p = lowest + life::scale_semitones(inputs.mode.value, y); + return p >= 0 && p <= 127 ? p : -1; + } + + // ------------------------------------------------------------------ world + + void restart() noexcept + { + m_ready = true; + m_step = 0; + m_column = -1; + m_reseeds = 0; + m_reseed_requested = false; + m_free_next = -1.; + m_free_index = 0; + m_rng = 0; + seed_world(true); + } + + void reseed() noexcept + { + m_reseeds++; + seed_world(false); + } + + uint64_t next_random() noexcept + { + // splitmix64 + uint64_t& s = inputs.seed.value != 0 ? m_rng : m_free_rng; + uint64_t z = (s += 0x9E3779B97F4A7C15ull); + z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9ull; + z = (z ^ (z >> 27)) * 0x94D049BB133111EBull; + return z ^ (z >> 31); + } + + void seed_world(bool first) noexcept + { + if(inputs.seed.value != 0) + m_rng = uint64_t(inputs.seed.value) * 0x2545F4914F6CDD1Dull + + uint64_t(m_reseeds) * 0x9E3779B97F4A7C15ull; + + m_cells = {}; + const int w = m_w, h = m_h; + if(inputs.start.value == StartRandom) + { + const double density = std::clamp(double(inputs.density.value), 0., 1.); + // 2^64 * density, saturated: 1 fills every cell + const uint64_t threshold = density >= 1. ? UINT64_MAX + : uint64_t(density * 18446744073709551616.); + for(int y = 0; y < h; y++) + for(int x = 0; x < w; x++) + if(next_random() < threshold) + m_cells[y] |= 1u << x; + } + else + { + const life::pattern_cells p = inputs.start.value == StartText + ? m_pattern.parsed + : life::parse_pattern(builtin_pattern()); + int x0 = (w - p.width) / 2; + int top = (h - p.height) / 2; + if(!first) + { + // A pattern comes back somewhere else, so the tune is new + x0 = int(next_random() % uint64_t(w)); + top = int(next_random() % uint64_t(h)); + } + for(int r = 0; r < p.height; r++) + for(int c = 0; c < p.width; c++) + { + if(!((p.rows[r] >> c) & 1u)) + continue; + int x = x0 + c, row = top + r; + if(m_wrap || !first) + { + x = ((x % w) + w) % w; + row = ((row % h) + h) % h; + } + if(x < 0 || x >= w || row < 0 || row >= h) + continue; + const int y = h - 1 - row; + m_cells[y] |= 1u << x; + } + } + + m_previous = {}; + m_generation = 0; + m_stale = 0; + m_history_count = 0; + m_history_pos = 0; + m_population = count_cells(m_cells); + remember(hash(m_cells)); + m_view_dirty = true; + } + + std::string_view builtin_pattern() const noexcept + { + switch(inputs.start.value) + { + case StartGlider: + return ".o./..o/ooo"; + case StartBlinker: + return "ooo"; + case StartToad: + return ".ooo/ooo."; + case StartRPentomino: + return ".oo/oo./.o."; + case StartAcorn: + return ".o...../...o.../oo..ooo"; + case StartDiehard: + return "......o./oo....../.o...ooo"; + case StartSpaceship: + return ".o..o/o..../o...o/oooo."; + case StartPentadecathlon: + return "..o....o../oo.oooo.oo/..o....o.."; + case StartPulsar: + return "..ooo...ooo../............./o....o.o....o/o....o.o....o/" + "o....o.o....o/..ooo...ooo../............./..ooo...ooo../" + "o....o.o....o/o....o.o....o/o....o.o....o/............./" + "..ooo...ooo.."; + default: + return ""; + } + } + + void evolve() noexcept + { + const life::grid next = next_generation(m_cells, m_w, m_h, m_wrap, m_rule); + const int pop = count_cells(next); + const uint64_t hsh = hash(next); + const bool repeating = seen(hsh); + + m_stale = repeating ? m_stale + 1 : 0; + if(inputs.auto_reseed.value + && (pop == 0 || m_stale > std::max(1, inputs.patience.value))) + { + reseed(); + return; + } + + m_previous = m_cells; + m_cells = next; + m_population = pop; + m_generation++; + remember(hsh); + } + + int count_cells(const life::grid& g) const noexcept + { + const uint32_t mask = m_w >= 32 ? 0xFFFFFFFFu : ((1u << m_w) - 1u); + int n = 0; + for(int y = 0; y < m_h; y++) + n += std::popcount(g[y] & mask); + return n; + } + + //! The cells the world shows, so that a change of size that keeps them is + //! not taken for a new generation. + uint64_t hash(const life::grid& g) const noexcept + { + const uint32_t mask = m_w >= 32 ? 0xFFFFFFFFu : ((1u << m_w) - 1u); + uint64_t h = 1469598103934665603ull; + for(int y = 0; y < m_h; y++) + { + h ^= g[y] & mask; + h *= 1099511628211ull; + } + return h; + } + + bool seen(uint64_t h) const noexcept + { + for(int i = 0; i < m_history_count; i++) + if(m_history[i] == h) + return true; + return false; + } + + void remember(uint64_t h) noexcept + { + m_history[m_history_pos] = h; + m_history_pos = (m_history_pos + 1) % int(m_history.size()); + m_history_count = std::min(m_history_count + 1, int(m_history.size())); + } + + // ----------------------------------------------------------------- output + + //! Per channel and pitch, how many notes sound: a note-on over a sounding + //! pitch re-attacks it, and the pitch ends with the last of its notes. + void send(libremidi::message&& m) noexcept + { + const int ts = int(m.timestamp); + const int c = channel_of(m); + const int p = m.bytes[1] & 127; + auto& n = m_sounding[(c - 1) & 15][p]; + if(is_note_on(m)) + { + if(n > 0) + push(libremidi::channel_events::note_off(c, p, 0), ts); + if(n < 0xFF) + n++; + push(std::move(m), ts); + } + else if(is_note_off(m)) + { + if(n == 0) + return; + if(--n == 0) + push(std::move(m), ts); + } + else + push(std::move(m), ts); + } + + void push(libremidi::message m, int64_t ts) noexcept + { + m.timestamp = ts; + outputs.midi.push_back(std::move(m)); + } + + void handle(const libremidi::message& m, int64_t ts) noexcept + { + if(is_all_notes_off(m)) + end_all(channel_of(m), ts); + push(m, ts); + } + + //! Ends what sounds on one channel or (0) all. Queued note-offs of those + //! notes find nothing sounding and are dropped. + void end_all(int channel, int64_t ts) noexcept + { + for(int c = 1; c <= 16; c++) + { + if(channel != 0 && c != channel) + continue; + for(int p = 0; p < 128; p++) + { + auto& n = m_sounding[c - 1][p]; + if(n > 0) + { + n = 0; + push(libremidi::channel_events::note_off(c, p, 0), ts); + } + } + } + } + + life::grid m_cells{}; + life::grid m_previous{}; + life::rule m_rule{}; + life::cached_text> m_custom_rule; + life::cached_text m_pattern; + std::array m_history{}; + int m_history_count{}; + int m_history_pos{}; + int m_stale{}; + + std::array, 16> m_sounding{}; + event_queue<2048> m_queue; + + double m_rate{48000.}; + double m_step_frames{6000.}; + int64_t m_now{}; + double m_free_next{-1.}; //!< frame of the next free step; -1: from the next tick + int64_t m_free_index{}; + double m_last_end_quarter{}; + int64_t m_step{}; + uint64_t m_rng{}; + uint64_t m_free_rng{}; + uint32_t m_generation{}; + int m_population{}; + int m_reseeds{}; + int m_column{-1}; + int m_w{16}; + int m_h{8}; + int m_frames{}; + int m_view_idle{}; + bool m_wrap{true}; + bool m_ready{}; + bool m_has_last{}; + bool m_running{}; + bool m_view_dirty{true}; + bool m_stop_requested{}; + bool m_seek_requested{}; + bool m_reseed_requested{}; +}; +} diff --git a/examples/Advanced/MidiScaler/MidiMpe.hpp b/examples/Advanced/MidiScaler/MidiMpe.hpp new file mode 100644 index 000000000..b19211649 --- /dev/null +++ b/examples/Advanced/MidiScaler/MidiMpe.hpp @@ -0,0 +1,1023 @@ +#pragma once + +/* SPDX-License-Identifier: GPL-3.0-or-later */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace mtk +{ +/** + * @brief Converts between plain MIDI and MPE (MIDI Polyphonic Expression, + * lower zone: master channel 1, member channels 2 and up), and shapes the + * per-note expression: pitch bend, pressure, and slide (CC 74). + * + * One object for the three directions: they share the zone, the note + * tracking and the expression shaping stage, which applies to the output of + * every mode. + */ +struct MidiMpe +{ + halp_meta(name, "Midi MPE") + halp_meta(c_name, "midi_mpe") + halp_meta(category, "Midi/Routing") + halp_meta(author, "ossia score") + halp_meta( + description, + "Convert MIDI to MPE and back, and shape or randomize per-note pitch bend, " + "pressure and slide") + halp_meta(manual_url, "https://ossia.io/score-docs/processes/midi-mpe.html") + halp_meta(uuid, "89a7457e-671e-4d3e-8ab7-45607ee7d0fe") + halp_flag(process_exec); + + static constexpr int master_channel = 1; + static constexpr int slide_cc = 74; + + struct mode_control + { + static clang_buggy_consteval auto name() { return std::string_view{"Mode"}; } + halp_meta( + description, + "MIDI to MPE: notes get a member channel each. MPE to MIDI: the zone is " + "collapsed to one channel. MPE to MPE: expression shaping only.") + enum enum_type + { + midi_to_mpe, + mpe_to_midi, + mpe_to_mpe + } value{midi_to_mpe}; + enum widget + { + combobox + }; + struct range + { + std::string_view values[3]{"MIDI to MPE", "MPE to MIDI", "MPE to MPE"}; + enum_type init = midi_to_mpe; + }; + operator enum_type&() noexcept { return value; } + operator const enum_type&() const noexcept { return value; } + auto& operator=(enum_type t) noexcept + { + value = t; + return *this; + } + }; + + struct route_control + { + static clang_buggy_consteval auto name() { return std::string_view{"Expression to"}; } + halp_meta( + description, + "MIDI to MPE: channel pitch bend, pressure and CC 74 go to the newest " + "note or to every held note") + enum enum_type + { + newest, + all + } value{newest}; + enum widget + { + combobox + }; + struct range + { + std::string_view values[2]{"Newest note", "All notes"}; + enum_type init = newest; + }; + operator enum_type&() noexcept { return value; } + operator const enum_type&() const noexcept { return value; } + auto& operator=(enum_type t) noexcept + { + value = t; + return *this; + } + }; + + struct ins + { + halp::midi_bus<"MIDI", libremidi::message> midi; + + mode_control mode; + + struct : halp::spinbox_i32<"Members", halp::range{1, 15, 15}> + { + halp_meta( + description, + "Member channels of the zone: channel 1 is the master channel, members " + "are channels 2 and up") + } members; + + struct : halp::spinbox_i32<"Input channel", halp::range{0, 16, 0}> + { + halp_meta(description, "MIDI to MPE: channel whose notes are spread (0: all)") + } input_channel; + + struct : halp::spinbox_i32<"Output channel", halp::range{1, 16, 1}> + { + halp_meta(description, "MPE to MIDI: channel the zone is collapsed to") + } output_channel; + + route_control route; + + struct : halp::toggle<"Send MPE config"> + { + halp_meta( + description, + "MIDI to MPE: send the MPE configuration message and the member pitch " + "bend range at start and when they change") + } send_config; + + struct : halp::spinbox_i32<"MIDI bend range", halp::range{1, 96, 2}> + { + halp_meta(description, "Pitch bend range in semitones of the plain MIDI side") + } midi_bend_range; + + struct : halp::spinbox_i32<"MPE bend range", halp::range{1, 96, 48}> + { + halp_meta(description, "Pitch bend range in semitones of the member channels") + } mpe_bend_range; + + struct : halp::hslider_f32<"Release reserve (ms)", halp::range{0., 1000., 0.}> + { + halp_meta( + description, + "MIDI to MPE: time in ms a member channel stays reserved after its note " + "ends, for the release of that note") + } reserve; + + struct : halp::knob_f32<"Pressure curve", halp::range{0.25, 4., 1.}> + { + halp_meta(description, "Exponent applied to pressure: above 1 softer, below 1 harder") + } pressure_curve; + + struct : halp::knob_f32<"Slide curve", halp::range{0.25, 4., 1.}> + { + halp_meta(description, "Exponent applied to slide (CC 74)") + } slide_curve; + + struct : halp::knob_f32<"Bend scale", halp::range{0., 2., 1.}> + { + halp_meta(description, "Scales the per-note pitch bend") + } bend_scale; + + struct : halp::knob_f32<"Random bend", halp::range{0., 2., 0.}> + { + halp_meta(description, "Per-note random detune drawn at note start, in semitones") + } random_bend; + + struct : halp::knob_f32<"Random pressure", halp::range{0., 1., 0.}> + { + halp_meta(description, "Per-note random pressure added from note start") + } random_pressure; + + struct : halp::knob_f32<"Random slide", halp::range{0., 1., 0.}> + { + halp_meta(description, "Per-note random slide offset drawn at note start") + } random_slide; + + struct : halp::spinbox_i32<"Seed", halp::range{0, 10000, 0}> + { + halp_meta(description, "0 is free running, other values repeat") + } seed; + } inputs; + + struct ui + { + halp_meta(name, "Midi MPE") + halp_meta(layout, halp::layouts::tabs) + halp_meta(background, halp::colors::background_mid) + + struct + { + halp_meta(name, "Zone") + halp_meta(layout, halp::layouts::hbox) + + struct + { + halp_meta(layout, halp::layouts::vbox) + halp::item<&ins::mode> mode; + halp::item<&ins::members> members; + } zone; + + struct + { + halp_meta(layout, halp::layouts::vbox) + struct + { + halp_meta(layout, halp::layouts::vbox) + static constexpr auto condition() + { + return halp::enabled_when<&ins::mode, mode_control::midi_to_mpe>{}; + } + halp::item<&ins::input_channel> input_channel; + } input; + + struct + { + halp_meta(layout, halp::layouts::vbox) + static constexpr auto condition() + { + return halp::enabled_when<&ins::mode, mode_control::mpe_to_midi>{}; + } + halp::item<&ins::output_channel> output_channel; + } output; + } channels; + + struct + { + halp_meta(layout, halp::layouts::vbox) + static constexpr auto condition() + { + return halp::enabled_when<&ins::mode, mode_control::midi_to_mpe>{}; + } + halp::item<&ins::route> route; + halp::item<&ins::send_config> send_config; + } to_mpe; + } zone; + + struct + { + halp_meta(name, "Bend") + halp_meta(layout, halp::layouts::hbox) + halp::item<&ins::midi_bend_range> midi_bend_range; + halp::item<&ins::mpe_bend_range> mpe_bend_range; + struct + { + halp_meta(layout, halp::layouts::vbox) + static constexpr auto condition() + { + return halp::enabled_when<&ins::mode, mode_control::midi_to_mpe>{}; + } + halp::item<&ins::reserve> reserve; + } reserve; + } bend; + + struct + { + halp_meta(name, "Shaping") + halp_meta(layout, halp::layouts::hbox) + halp::item<&ins::pressure_curve> pressure_curve; + halp::item<&ins::slide_curve> slide_curve; + halp::item<&ins::bend_scale> bend_scale; + } shaping; + + struct + { + halp_meta(name, "Random") + halp_meta(layout, halp::layouts::hbox) + halp::item<&ins::random_bend> random_bend; + halp::item<&ins::random_pressure> random_pressure; + halp::item<&ins::random_slide> random_slide; + halp::item<&ins::seed> seed; + } random; + }; + + struct + { + halp::midi_bus<"MIDI", libremidi::message> midi; + } outputs; + + MidiMpe() + { + std::random_device rd; + m_free_seed[0] = (uint64_t(rd()) << 32) ^ uint64_t(rd()); + m_free_seed[1] = (uint64_t(rd()) << 32) ^ uint64_t(rd()); + reseed(); + } + + void prepare(halp::setup info) noexcept + { + if(info.rate > 0.) + m_rate = info.rate; + } + + //! Sends a message, and frees the channel of each note-off it sends. + auto emitter() noexcept + { + return [this](libremidi::message&& m) noexcept { + if(is_note_off(m) && m_mode == mode_control::midi_to_mpe) + { + const int ch = channel_of(m); + if(m_taken[ch - 1] > 0) + { + m_taken[ch - 1]--; + m_alloc.release(ch, m_now + m.timestamp); + } + } + outputs.midi.push_back(std::move(m)); + }; + } + + //! Sends a message that is not one of the tracked notes: the member + //! channels stay as they are. + void pass(libremidi::message&& m) noexcept { outputs.midi.push_back(std::move(m)); } + + void stop() noexcept { m_stop = true; } + + using tick = halp::tick; + void operator()(halp::tick t) noexcept + { + m_frames = t.frames > 0 ? t.frames : 0; + auto emit = emitter(); + + if(m_stop) + { + m_notes.end_all(0, emit); + m_alloc.reset(); + m_taken = {}; + reset_expression(); + reseed(); + m_stop = false; + } + + configure(emit); + + for(const auto& in : inputs.midi) + { + auto m = in; + m.timestamp = m_frames > 0 ? std::clamp(in.timestamp, 0, m_frames - 1) : 0; + switch(m_mode) + { + case mode_control::midi_to_mpe: + handle_midi_to_mpe(m, emit); + break; + case mode_control::mpe_to_midi: + handle_mpe_to_midi(m, emit); + break; + case mode_control::mpe_to_mpe: + handle_mpe_to_mpe(m, emit); + break; + } + } + + m_now += m_frames; + } + +private: + //! Per-channel expression as received, plus the per-note random offsets. + struct expression + { + double bend{}; //!< semitones + int pressure{}; //!< 0 - 127 + int slide{64}; //!< 0 - 127 + double rbend{}; //!< semitones + double rpressure{}; //!< 0 - 127 + double rslide{}; //!< -127 - 127 + }; + + //! Last values sent on a channel, -1 when unknown. + struct sent + { + int bend{-1}, pressure{-1}, slide{-1}; + }; + + int last_member() const noexcept { return master_channel + m_members; } + bool in_zone(int ch) const noexcept { return ch >= master_channel && ch <= last_member(); } + bool is_member(int ch) const noexcept { return ch > master_channel && ch <= last_member(); } + + template + void configure(Emit& emit) noexcept + { + const auto mode = inputs.mode.value; + const int members = std::clamp(int(inputs.members.value), 1, 15); + const int in_ch = std::clamp(int(inputs.input_channel.value), 0, 16); + const int out_ch = std::clamp(int(inputs.output_channel.value), 1, 16); + if(mode != m_mode || members != m_members || in_ch != m_input_channel + || out_ch != m_output_channel) + { + // Held notes were placed for the previous settings: their note-offs + // could no longer be matched. + m_notes.end_all(0, emit); + m_mode = mode; + m_members = members; + m_input_channel = in_ch; + m_output_channel = out_ch; + m_alloc.reset(); + m_taken = {}; + reset_expression(); + m_config_sent = false; + m_mpe_range = m_mpe_range_control; + m_param = {}; + } + + uint16_t mask = 0; + for(int ch = master_channel + 1; ch <= last_member(); ch++) + mask |= uint16_t(1u << (ch - 1)); + m_alloc.set_channels(mask); + m_alloc.set_mode(channel_allocator::mode::round_robin); + m_alloc.set_release_reserve(int64_t( + std::llround(std::clamp(double(inputs.reserve.value), 0., 1000.) * m_rate / 1000.))); + + m_midi_range = std::clamp(int(inputs.midi_bend_range.value), 1, 96); + const int mpe_range = std::clamp(int(inputs.mpe_bend_range.value), 1, 96); + // A range announced by the MPE input (RPN 0 on a member channel) is used + // until the control changes. + if(mpe_range != m_mpe_range_control) + { + m_mpe_range_control = mpe_range; + m_mpe_range = mpe_range; + m_config_sent = false; + } + + m_pressure_curve = std::clamp(double(inputs.pressure_curve.value), 0.25, 4.); + m_slide_curve = std::clamp(double(inputs.slide_curve.value), 0.25, 4.); + m_bend_scale = std::clamp(double(inputs.bend_scale.value), 0., 2.); + m_random_bend = std::clamp(double(inputs.random_bend.value), 0., 2.); + m_random_pressure = std::clamp(double(inputs.random_pressure.value), 0., 1.); + m_random_slide = std::clamp(double(inputs.random_slide.value), 0., 1.); + + if(inputs.seed.value != m_seed) + { + m_seed = inputs.seed.value; + reseed(); + } + + if(!inputs.send_config.value) + m_config_sent = false; + else if(!m_config_sent && m_mode == mode_control::midi_to_mpe) + { + send_config(emit); + m_config_sent = true; + } + } + + template + void send_rpn(int ch, int rpn, int value, Emit& emit) noexcept + { + const uint8_t seq[6][2] + = {{101, 0}, {100, uint8_t(rpn)}, {6, uint8_t(value)}, {38, 0}, {101, 127}, {100, 127}}; + for(auto& cc : seq) + { + auto m = libremidi::channel_events::control_change(ch, cc[0], cc[1]); + m.timestamp = 0; + emit(std::move(m)); + } + } + + //! MPE configuration message (RPN 6 on the master channel), then the pitch + //! bend range of each member (RPN 0), since the former resets the latter + //! to 48 semitones. + template + void send_config(Emit& emit) noexcept + { + send_rpn(master_channel, 6, m_members, emit); + for(int ch = master_channel + 1; ch <= last_member(); ch++) + send_rpn(ch, 0, m_mpe_range, emit); + } + + void reseed() noexcept + { + if(m_seed != 0) + m_rng.seed(uint64_t(m_seed), UINT64_C(0x9e3779b97f4a7c15)); + else + m_rng.seed(m_free_seed[0], m_free_seed[1]); + } + + double random01() noexcept { return double(m_rng()) / 4294967296.0; } + + void reset_expression() noexcept + { + m_expr = {}; + m_sent = {}; + m_out_sent = {}; + m_in = {}; + m_master_bend = 0.; + } + + //! New random offsets for the note starting on a channel. + void draw_offsets(int ch) noexcept + { + auto& e = m_expr[ch - 1]; + e.rbend = m_random_bend > 0. ? (random01() * 2. - 1.) * m_random_bend : 0.; + e.rpressure = m_random_pressure > 0. ? random01() * m_random_pressure * 127. : 0.; + e.rslide = m_random_slide > 0. ? (random01() * 2. - 1.) * m_random_slide * 127. : 0.; + } + + bool randomizing() const noexcept + { + return m_random_bend > 0. || m_random_pressure > 0. || m_random_slide > 0.; + } + + static double bend_to_semitones(const libremidi::message& m, int range) noexcept + { + if(m.size() < 3) + return 0.; + const int v = m.bytes[1] | (m.bytes[2] << 7); + return double(v - 8192) / 8192. * range; + } + + static int semitones_to_bend(double semitones, int range) noexcept + { + const double v = 8192. + std::round(semitones / double(range) * 8192.); + return int(std::clamp(v, 0., 16383.)); + } + + static int curve(int v, double exponent) noexcept + { + if(exponent == 1.) + return v; + return int(std::lround(127. * std::pow(std::clamp(v, 0, 127) / 127., exponent))); + } + + double shaped_bend(int ch) const noexcept + { + const auto& e = m_expr[ch - 1]; + return e.bend * m_bend_scale + e.rbend; + } + + int shaped_pressure(int ch) const noexcept + { + const auto& e = m_expr[ch - 1]; + return std::clamp( + int(std::lround(curve(e.pressure, m_pressure_curve) + e.rpressure)), 0, 127); + } + + int shaped_slide(int ch) const noexcept + { + const auto& e = m_expr[ch - 1]; + return std::clamp(int(std::lround(curve(e.slide, m_slide_curve) + e.rslide)), 0, 127); + } + + template + void emit_bend(int ch, int value, int64_t ts, Emit& emit) noexcept + { + auto m = libremidi::channel_events::pitch_bend(ch, value); + m.timestamp = ts; + emit(std::move(m)); + } + + template + void emit_pressure(int ch, int value, int64_t ts, Emit& emit) noexcept + { + auto m = libremidi::channel_events::aftertouch(ch, value); + m.timestamp = ts; + emit(std::move(m)); + } + + template + void emit_slide(int ch, int value, int64_t ts, Emit& emit) noexcept + { + auto m = libremidi::channel_events::control_change(ch, slide_cc, value); + m.timestamp = ts; + emit(std::move(m)); + } + + //! Sends the shaped expression of a member channel, skipping values the + //! channel already has unless `force`. + template + void send_member(int ch, int64_t ts, bool force, Emit& emit, bool bend = true, + bool pressure = true, bool slide = true) noexcept + { + auto& s = m_sent[ch - 1]; + if(bend) + { + const int v = semitones_to_bend(shaped_bend(ch), m_mpe_range); + if(force || v != s.bend) + emit_bend(ch, s.bend = v, ts, emit); + } + if(slide) + { + const int v = shaped_slide(ch); + if(force || v != s.slide) + emit_slide(ch, s.slide = v, ts, emit); + } + if(pressure) + { + const int v = shaped_pressure(ch); + if(force || v != s.pressure) + emit_pressure(ch, s.pressure = v, ts, emit); + } + } + + //! Output channel of the newest held note, if any. + std::optional newest_output() const noexcept + { + for(int i = m_notes.size() - 1; i >= 0; i--) + if(m_notes[i].count > 0) + return m_notes[i].outputs[0].channel; + return std::nullopt; + } + + //! Input channel of the newest held note, if any. + std::optional newest_input() const noexcept + { + if(m_notes.size() == 0) + return std::nullopt; + return m_notes[m_notes.size() - 1].channel; + } + + bool held_on_input(int ch) const noexcept + { + for(int i = 0; i < m_notes.size(); i++) + if(m_notes[i].channel == ch) + return true; + return false; + } + + // ------------------------------------------------------------ MIDI to MPE + + template + void handle_midi_to_mpe(libremidi::message& m, Emit& emit) noexcept + { + const int64_t ts = m.timestamp; + const int ch = channel_of(m); + if(ch == 0 || (m_input_channel != 0 && ch != m_input_channel)) + { + pass(std::move(m)); + return; + } + + switch(classify(m)) + { + case midi_kind::note_on: { + const int pitch = m.bytes[1]; + auto member = m_alloc.allocate(m_now + ts); + if(!member) + { + // Every member is busy: the one with the fewest notes is shared. + int best = 0; + for(int c = master_channel + 1; c <= last_member(); c++) + if(m_notes.sounding(c, pitch) == 0 + && (best == 0 || m_alloc.held(c) < m_alloc.held(best))) + best = c; + if(best == 0) + break; + m_alloc.take(best); + member = best; + } + const int c = *member; + const bool fresh = m_taken[c - 1] == 0; + m_taken[c - 1]++; + if(fresh) + draw_offsets(c); + m_expr[c - 1].bend = m_in.bend; + m_expr[c - 1].pressure = m_in.pressure; + m_expr[c - 1].slide = m_in.slide; + send_member(c, ts, true, emit); + + auto& h = m_notes.begin(ch, pitch, ts, emit); + m_notes.play(h, c, pitch, m.bytes[2], ts, emit); + break; + } + + case midi_kind::note_off: + if(!m_notes.end(ch, m.bytes[1], ts, m.bytes[2], emit)) + pass(std::move(m)); + break; + + case midi_kind::pitch_bend: + m_in.bend = bend_to_semitones(m, m_midi_range); + route(ts, emit, [&](int c) { + m_expr[c - 1].bend = m_in.bend; + send_member(c, ts, false, emit, true, false, false); + }); + break; + + case midi_kind::channel_pressure: + if(m.size() < 2) + break; + m_in.pressure = m.bytes[1]; + route(ts, emit, [&](int c) { + m_expr[c - 1].pressure = m_in.pressure; + send_member(c, ts, false, emit, false, true, false); + }); + break; + + case midi_kind::poly_pressure: { + if(m.size() < 3) + break; + // Per-note pressure becomes the channel pressure of the note's member. + for(int i = 0; i < m_notes.size(); i++) + { + const auto& h = m_notes[i]; + if(h.channel == ch && h.pitch == m.bytes[1] && h.count > 0) + { + const int c = h.outputs[0].channel; + m_expr[c - 1].pressure = m.bytes[2]; + send_member(c, ts, false, emit, false, true, false); + break; + } + } + break; + } + + case midi_kind::control_change: + if(m.bytes[1] == slide_cc) + { + m_in.slide = m.bytes[2]; + route(ts, emit, [&](int c) { + m_expr[c - 1].slide = m_in.slide; + send_member(c, ts, false, emit, false, false, true); + }); + } + else if(is_all_notes_off(m)) + { + m_notes.end_all(ts, emit); + for(int c = master_channel; c <= last_member(); c++) + { + auto msg = m; + set_channel(msg, c); + emit(std::move(msg)); + } + } + else + { + set_channel(m, master_channel); + emit(std::move(m)); + } + break; + + default: + set_channel(m, master_channel); + emit(std::move(m)); + break; + } + } + + //! Calls `f` with the member channel of the newest note, or of every held + //! note, each channel once. + template + void route(int64_t ts, Emit& emit, F&& f) noexcept + { + if(inputs.route.value == route_control::newest) + { + if(auto c = newest_output()) + f(*c); + return; + } + for(int c = master_channel + 1; c <= last_member(); c++) + if(m_taken[c - 1] > 0) + f(c); + } + + // ------------------------------------------------------------ MPE to MIDI + + template + void handle_mpe_to_midi(libremidi::message& m, Emit& emit) noexcept + { + const int64_t ts = m.timestamp; + const int ch = channel_of(m); + if(!in_zone(ch)) + { + emit(std::move(m)); + return; + } + + switch(classify(m)) + { + case midi_kind::note_on: { + if(!held_on_input(ch)) + draw_offsets(ch); + auto& h = m_notes.begin(ch, m.bytes[1], ts, emit); + update_collapsed(ts, emit); + m_notes.play(h, m_output_channel, m.bytes[1], m.bytes[2], ts, emit); + break; + } + + case midi_kind::note_off: + if(m_notes.end(ch, m.bytes[1], ts, m.bytes[2], emit)) + update_collapsed(ts, emit); + else + { + set_channel(m, m_output_channel); + emit(std::move(m)); + } + break; + + case midi_kind::pitch_bend: + if(ch == master_channel) + m_master_bend = bend_to_semitones(m, m_midi_range); + else + m_expr[ch - 1].bend = bend_to_semitones(m, m_mpe_range); + update_collapsed(ts, emit); + break; + + case midi_kind::channel_pressure: + if(m.size() < 2) + break; + m_expr[ch - 1].pressure = m.bytes[1]; + update_collapsed(ts, emit); + break; + + case midi_kind::control_change: + if(m.bytes[1] == slide_cc && ch != master_channel) + { + m_expr[ch - 1].slide = m.bytes[2]; + update_collapsed(ts, emit); + break; + } + if(ch != master_channel && is_parameter_cc(m.bytes[1])) + { + // Member channels configure the MPE input; on the single output + // channel these sequences would retune or reconfigure the receiver. + read_member_parameter(ch, m.bytes[1], m.bytes[2]); + break; + } + if(is_all_notes_off(m)) + m_notes.end_all(ts, emit); + set_channel(m, m_output_channel); + emit(std::move(m)); + break; + + default: + set_channel(m, m_output_channel); + emit(std::move(m)); + break; + } + } + + //! Bank select excluded: the CCs that select or set an RPN or NRPN. + static constexpr bool is_parameter_cc(int cc) noexcept + { + return cc == 6 || cc == 38 || (cc >= 96 && cc <= 101); + } + + //! Follows RPN 0 (pitch bend range) on a member channel. + void read_member_parameter(int ch, int cc, int value) noexcept + { + auto& p = m_param[ch - 1]; + switch(cc) + { + case 101: + p.msb = value; + p.rpn = true; + break; + case 100: + p.lsb = value; + p.rpn = true; + break; + case 99: + p.msb = value; + p.rpn = false; + break; + case 98: + p.lsb = value; + p.rpn = false; + break; + case 6: + if(p.rpn && p.msb == 0 && p.lsb == 0) + m_mpe_range = std::clamp(value, 1, 96); + break; + default: + break; + } + } + + //! Output bend and slide follow the newest note, pressure is the highest + //! of the held notes. + template + void update_collapsed(int64_t ts, Emit& emit) noexcept + { + const auto newest = newest_input(); + if(!newest) + return; + + const int out = m_output_channel; + const double semis + = m_master_bend + (*newest == master_channel ? 0. : shaped_bend(*newest)); + const int bend = semitones_to_bend(semis, m_midi_range); + if(bend != m_out_sent.bend) + emit_bend(out, m_out_sent.bend = bend, ts, emit); + + const int slide = shaped_slide(*newest); + if(slide != m_out_sent.slide) + emit_slide(out, m_out_sent.slide = slide, ts, emit); + + int pressure = 0; + for(int i = 0; i < m_notes.size(); i++) + pressure = std::max(pressure, shaped_pressure(m_notes[i].channel)); + if(pressure != m_out_sent.pressure) + emit_pressure(out, m_out_sent.pressure = pressure, ts, emit); + } + + // ------------------------------------------------------------ MPE to MPE + + template + void handle_mpe_to_mpe(libremidi::message& m, Emit& emit) noexcept + { + const int64_t ts = m.timestamp; + const int ch = channel_of(m); + const bool member = is_member(ch); + + switch(classify(m)) + { + case midi_kind::note_on: { + if(member && !held_on_input(ch)) + { + draw_offsets(ch); + if(randomizing()) + send_member(ch, ts, true, emit); + } + auto& h = m_notes.begin(ch, m.bytes[1], ts, emit); + m_notes.play(h, ch, m.bytes[1], m.bytes[2], ts, emit); + break; + } + + case midi_kind::note_off: + if(!m_notes.end(ch, m.bytes[1], ts, m.bytes[2], emit)) + emit(std::move(m)); + break; + + case midi_kind::pitch_bend: + if(!member) + { + emit(std::move(m)); + break; + } + m_expr[ch - 1].bend = bend_to_semitones(m, m_mpe_range); + send_member(ch, ts, true, emit, true, false, false); + break; + + case midi_kind::channel_pressure: + if(!member || m.size() < 2) + { + emit(std::move(m)); + break; + } + m_expr[ch - 1].pressure = m.bytes[1]; + send_member(ch, ts, true, emit, false, true, false); + break; + + case midi_kind::control_change: + if(member && m.bytes[1] == slide_cc) + { + m_expr[ch - 1].slide = m.bytes[2]; + send_member(ch, ts, true, emit, false, false, true); + break; + } + if(is_all_notes_off(m)) + m_notes.end_all(ts, emit); + emit(std::move(m)); + break; + + default: + emit(std::move(m)); + break; + } + } + + note_tracker<128, 1> m_notes; + channel_allocator m_alloc; + //! Note-ons sent on each member channel and not yet ended (MIDI to MPE). + std::array m_taken{}; + + std::array m_expr{}; + std::array m_sent{}; + sent m_out_sent{}; + //! Current channel-wide expression of the plain MIDI input. + struct + { + double bend{}; + int pressure{}; + int slide{64}; + } m_in; + double m_master_bend{}; + + rnd::pcg m_rng; + uint64_t m_free_seed[2]{}; + int m_seed{0}; + + mode_control::enum_type m_mode{mode_control::midi_to_mpe}; + int m_members{15}; + int m_input_channel{0}; + int m_output_channel{1}; + int m_midi_range{2}; + int m_mpe_range{48}; + int m_mpe_range_control{48}; + //! Parameter selected on each channel of the MPE input; 127/127 is none. + struct selected_parameter + { + int msb{127}, lsb{127}; + bool rpn{true}; + }; + std::array m_param{}; + double m_pressure_curve{1.}; + double m_slide_curve{1.}; + double m_bend_scale{1.}; + double m_random_bend{}; + double m_random_pressure{}; + double m_random_slide{}; + + double m_rate{48000.}; + int64_t m_now{}; + int m_frames{}; + bool m_config_sent{false}; + bool m_stop{false}; +}; +} diff --git a/examples/Advanced/MidiScaler/MidiProgressionPlayer.hpp b/examples/Advanced/MidiScaler/MidiProgressionPlayer.hpp new file mode 100644 index 000000000..f96a45e75 --- /dev/null +++ b/examples/Advanced/MidiScaler/MidiProgressionPlayer.hpp @@ -0,0 +1,1117 @@ +#pragma once + +/* SPDX-License-Identifier: GPL-3.0-or-later */ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mtk +{ +/** + * @brief Plays a chord progression and keeps incoming notes in its harmony. + * + * The progression is one of the 194 of free-midi-chords (ProgressionTable.hpp) + * or a custom text in the same roman-numeral notation (Progressions.hpp), in + * a key. Each step plays the next chord, voiced near Register, each chord in + * the inversion closest to the previous one (or in root position). + * + * Steps come from the musical grid (Every step), from the Next button, or from + * each incoming note-on. On the grid, the chord is a function of the position: + * step k of the timeline plays chord k modulo the length of the progression, + * so that playing from anywhere, looping or rewinding gives the same chord at + * the same place. A step shorter than a whole note restarts at every bar line + * (a 3/4 bar in half notes has steps on beats 1 and 3); a whole note or more is + * counted in bars from the last signature change, like the quantification + * grid of score. + * + * Incoming notes pass, are snapped to the nearest tone of the current chord or + * of the scale, or, with Keys play degrees, white keys from C4 play the chord's + * tones upwards over octaves (C4 the root nearest C4, D4 the next tone...) + * while black keys are silent. A note keeps the pitch it got at its note-on + * until its note-off. Incoming notes on a rest follow the previous chord. + * + * Outputs give the current chord's name, its symbol as written, its index in + * the progression and its root's pitch class (-1 on a rest), for other + * processes to follow the harmony. A custom text that does not parse shows + * the reason in Error and the last text that parsed keeps playing. + * + * Every note-on sent gets exactly one note-off: notes are counted per channel + * and pitch, a note started twice is re-attacked and ends with the last of + * them. All notes off (CC 120 / 123) ends what sounds on its channel and + * passes; stop and rewind end everything. Note-offs that match no note-on + * received are dropped. Other messages pass unchanged. + */ +struct MidiProgressionPlayer +{ + halp_meta(name, "Midi progression player") + halp_meta(c_name, "midi_progression_player") + halp_meta(category, "Midi/Generators") + halp_meta(author, "ossia score") + halp_meta( + description, "Play a chord progression in a key, step by step, and keep " + "incoming notes in its harmony") + halp_meta(manual_url, "https://ossia.io/score-docs/processes/midi-progression-player.html") + halp_meta(uuid, "213d75e7-3c2e-4b13-9686-fbfd2132fc2d") + halp_flag(process_exec); + + static constexpr int max_held = 128; + static constexpr int custom_index = 0; + + //! Index in the Progression list: 0 is Custom, i + 1 is progression_table[i]. + enum class progression_choice : uint16_t + { + }; + + static constexpr auto progression_labels = [] { + std::array a{}; + a[0] = "Custom"; + for(int i = 0; i < progression_count; i++) + a[i + 1] = progression_table[i].label; + return a; + }(); + + static constexpr progression_choice default_progression = [] { + for(int i = 0; i < progression_count; i++) + if(progression_table[i].symbols == "I V vi IV") + return progression_choice(i + 1); + return progression_choice(1); + }(); + + enum class key_mode : uint8_t + { + automatic, + major, + minor, + dorian, + phrygian, + lydian, + mixolydian, + locrian, + harmonic_minor, + melodic_minor + }; + + enum class advance_mode : uint8_t + { + every_step, + on_trigger, + on_note + }; + + enum class input_mode : uint8_t + { + pass, + snap_chord, + snap_scale, + degrees + }; + + struct ins + { + halp::midi_bus<"MIDI", libremidi::message> midi; + + // ---- Harmony --------------------------------------------------------- + struct + { + static clang_buggy_consteval auto name() { return std::string_view{"Key"}; } + halp_meta(description, "Root of the key") + enum enum_type + { + C, Cs, D, Eb, E, F, Fs, G, Ab, A, Bb, B + } value{C}; + enum widget + { + enumeration, + list, + combobox + }; + struct range + { + std::string_view values[12]{"C", "C#", "D", "Eb", "E", "F", + "F#", "G", "Ab", "A", "Bb", "B"}; + enum_type init = C; + }; + operator enum_type&() noexcept { return value; } + operator const enum_type&() const noexcept { return value; } + auto& operator=(enum_type t) noexcept + { + value = t; + return *this; + } + } key; + + struct + { + static clang_buggy_consteval auto name() { return std::string_view{"Mode"}; } + halp_meta( + description, + "Mode of the key: the chords of bare numerals (ii, V7) come from it. " + "Auto: minor for the minor progressions of the list, major otherwise.") + key_mode value{key_mode::automatic}; + enum widget + { + enumeration, + list, + combobox + }; + struct range + { + std::string_view values[10]{ + "Auto", "Major", "Minor", "Dorian", "Phrygian", + "Lydian", "Mixolydian", "Locrian", "Harmonic minor", "Melodic minor"}; + key_mode init = key_mode::automatic; + }; + operator key_mode&() noexcept { return value; } + operator const key_mode&() const noexcept { return value; } + auto& operator=(key_mode t) noexcept + { + value = t; + return *this; + } + } mode; + + struct + { + static clang_buggy_consteval auto name() { return std::string_view{"Progression"}; } + halp_meta( + description, + "One of the 194 progressions of free-midi-chords, with their moods, or " + "Custom to write one") + progression_choice value{default_progression}; + enum widget + { + enumeration, + list, + combobox + }; + struct range + { + std::array values = progression_labels; + progression_choice init = default_progression; + }; + operator progression_choice&() noexcept { return value; } + operator const progression_choice&() const noexcept { return value; } + auto& operator=(progression_choice t) noexcept + { + value = t; + return *this; + } + } progression; + + struct : halp::lineedit<"Custom", "I vi IV V"> + { + halp_meta( + description, + "Roman numerals separated by spaces, e.g. ii7 V7 I7 or i bVIIM bVIM V7. " + "Bare numerals take the chord of the key, 7 its seventh; suffixes M m " + "dim dom7 M7 m7 sus2 sus4 6 9 add9 5 and more; b or # before the " + "numeral; I/bVII for a bass; X or two spaces for a rest.") + } custom; + + // ---- Advance --------------------------------------------------------- + struct + { + static clang_buggy_consteval auto name() { return std::string_view{"Advance"}; } + halp_meta( + description, + "Every step: on the musical grid. On trigger: at each Next. On note: at " + "each incoming note-on.") + advance_mode value{advance_mode::every_step}; + enum widget + { + enumeration, + list, + combobox + }; + struct range + { + std::string_view values[3]{"Every step", "On trigger", "On note"}; + advance_mode init = advance_mode::every_step; + }; + operator advance_mode&() noexcept { return value; } + operator const advance_mode&() const noexcept { return value; } + auto& operator=(advance_mode t) noexcept + { + value = t; + return *this; + } + } advance; + + struct : halp::time_chooser<"Step", halp::range{0.05, 32., 2.}> + { + halp_meta( + description, + "Length of a chord. Synced: a note value on the musical grid (1 is a " + "bar of 4/4, steps shorter than a whole note restart at each bar). " + "Free: seconds from the start.") + } step; + + struct : halp::knob_f32<"Gate", halp::range{0.01, 1., 1.}> + { + halp_meta( + description, "Chord length as a proportion of the step; 1 holds it until " + "the next chord") + } gate; + + struct : halp::impulse_button<"Next"> + { + halp_meta(description, "Play the next chord (On trigger)") + } next; + + struct : halp::toggle<"Consume", halp::toggle_setup{.init = true}> + { + halp_meta( + description, + "On note: the notes that step the progression are not passed on") + } consume; + + // ---- Chords ---------------------------------------------------------- + struct : halp::toggle<"Chords", halp::toggle_setup{.init = true}> + { + halp_meta(description, "Play the chords; off, only the input is shaped") + } chords; + + struct : halp::spinbox_i32<"Register", halp::irange{24, 108, 60}> + { + halp_meta(description, "Center note of the chords (60 is C4)") + } center; + + struct + { + static clang_buggy_consteval auto name() { return std::string_view{"Inversion"}; } + halp_meta( + description, + "Root: every chord in root position. Closest: the inversion that " + "moves the voices the least from the previous chord.") + inversion_mode value{inversion_mode::closest}; + enum widget + { + enumeration, + list, + combobox + }; + struct range + { + std::string_view values[2]{"Root", "Closest"}; + inversion_mode init = inversion_mode::closest; + }; + operator inversion_mode&() noexcept { return value; } + operator const inversion_mode&() const noexcept { return value; } + auto& operator=(inversion_mode t) noexcept + { + value = t; + return *this; + } + } inversion; + + struct : halp::toggle<"Bass"> + { + halp_meta( + description, "Add the root (or the bass of a slash chord) an octave " + "below the register") + } bass; + + struct : halp::spinbox_i32<"Notes", halp::irange{1, 6, 6}> + { + halp_meta( + description, "Chord tones played at most; the fifth goes first, then " + "the extensions") + } notes; + + struct : halp::hslider_i32<"Velocity", halp::irange{1, 127, 90}> + { + halp_meta(description, "Velocity of the chord notes") + } velocity; + + struct : halp::spinbox_i32<"Channel", halp::irange{1, 16, 1}> + { + halp_meta(description, "MIDI channel of the chord notes") + } channel; + + // ---- Input ----------------------------------------------------------- + struct + { + static clang_buggy_consteval auto name() { return std::string_view{"Input notes"}; } + halp_meta( + description, + "Pass: unchanged. Snap to chord / scale: moved to the nearest tone of " + "the current chord / of the key. Keys play degrees: white keys from C4 " + "play the chord tones upwards, black keys are silent.") + input_mode value{input_mode::pass}; + enum widget + { + enumeration, + list, + combobox + }; + struct range + { + std::string_view values[4]{ + "Pass", "Snap to chord", "Snap to scale", "Keys play degrees"}; + input_mode init = input_mode::pass; + }; + operator input_mode&() noexcept { return value; } + operator const input_mode&() const noexcept { return value; } + auto& operator=(input_mode t) noexcept + { + value = t; + return *this; + } + } input; + } inputs; + + struct + { + halp::midi_bus<"MIDI", libremidi::message> midi; + struct : halp::val_port<"Chord", std::string> + { + halp_meta(description, "Name of the current chord, e.g. Am7; empty on a rest") + } chord; + struct : halp::val_port<"Degree", std::string> + { + halp_meta(description, "Symbol of the current chord as written, e.g. vi7") + } degree; + struct : halp::val_port<"Index", int> + { + halp_meta(description, "Position of the current chord in the progression, from 0") + } index; + struct : halp::val_port<"Root", int> + { + halp_meta(description, "Pitch class of the current chord's root, 0 is C; -1 on a rest") + } root; + struct : halp::val_port<"Error", std::string> + { + halp_meta(description, "Why the custom progression does not parse; empty if it does") + } error; + } outputs; + + struct ui + { + halp_meta(name, "Midi progression player") + halp_meta(layout, halp::layouts::hbox) + halp_meta(background, halp::colors::background_dark) + + struct + { + halp_meta(name, "Harmony") + halp_meta(layout, halp::layouts::section) + halp_meta(background, halp::colors::background_mid) + struct + { + halp_meta(layout, halp::layouts::hbox) + halp::item<&ins::key> key; + halp::item<&ins::mode> mode; + } key; + halp::item<&ins::progression> progression; + struct + { + halp_meta(layout, halp::layouts::vbox) + static constexpr auto condition() + { + // An int: score matches enums by their enumerators, which this one has not + return halp::enabled_when<&ins::progression, custom_index>{}; + } + halp::item<&ins::custom> custom; + } custom; + halp::item<&ins::input> input; + } harmony; + + struct + { + halp_meta(name, "Settings") + halp_meta(layout, halp::layouts::tabs) + halp_meta(background, halp::colors::background_darker) + + struct + { + halp_meta(name, "Advance") + halp_meta(layout, halp::layouts::vbox) + halp::item<&ins::advance, halp::look{.widget = halp::control_widget::combo}> + advance; + struct + { + halp_meta(layout, halp::layouts::hbox) + static constexpr auto condition() + { + return halp::enabled_when<&ins::advance, advance_mode::every_step>{}; + } + halp::item<&ins::step> step; + halp::item<&ins::gate> gate; + } grid; + struct + { + halp_meta(layout, halp::layouts::hbox) + struct + { + halp_meta(layout, halp::layouts::vbox) + static constexpr auto condition() + { + return halp::enabled_when<&ins::advance, advance_mode::on_trigger>{}; + } + halp::item<&ins::next> next; + } trigger; + struct + { + halp_meta(layout, halp::layouts::vbox) + static constexpr auto condition() + { + return halp::enabled_when<&ins::advance, advance_mode::on_note>{}; + } + halp::item<&ins::consume> consume; + } note; + } manual; + } advance; + + struct + { + halp_meta(name, "Chords") + halp_meta(layout, halp::layouts::vbox) + halp::item<&ins::chords> chords; + struct + { + halp_meta(layout, halp::layouts::grid) + halp_meta(columns, 2) + static constexpr auto condition() + { + return halp::enabled_when<&ins::chords, true>{}; + } + halp::item<&ins::center> center; + halp::item<&ins::notes> notes; + halp::item<&ins::inversion> inversion; + halp::item<&ins::bass> bass; + halp::item<&ins::velocity> velocity; + halp::item<&ins::channel> channel; + } voicing; + } chords; + } settings; + }; + + MidiProgressionPlayer() noexcept + { + outputs.chord.value.reserve(max_text); + outputs.degree.value.reserve(max_text); + outputs.error.value.reserve(max_text); + outputs.index.value = -1; + outputs.root.value = -1; + } + + void prepare(halp::setup s) noexcept + { + if(s.rate > 0.) + m_rate = s.rate; + } + + void stop() noexcept { m_stop_requested = true; } + + //! The host moved the transport to `date`: the next tick starts the take + //! over from there. + template + void transport(std::chrono::duration date) noexcept + { + m_seek_requested = true; + m_seek_seconds = std::chrono::duration(date).count(); + } + + using tick = halp::tick_musical; + void operator()(const halp::tick_musical& t) noexcept + { + m_frames = t.frames > 0 ? t.frames : 0; + update_progression(); + + if(m_stop_requested) + { + m_stop_requested = false; + end_all(0, 0, true); + reset(); + m_free_position = 0; + } + + // A seek, a rewind or a loop restarts the take. position_in_frames only + // ever grows: a seek is told by transport(), or seen as the musical + // position going back. + const bool musical = m_frames > 0 && t.end_position_in_quarters != t.start_position_in_quarters; + if(std::exchange(m_seek_requested, false)) + { + if(m_started) + { + end_all(0, 0, false); + reset(); + } + m_free_position = std::max(0, std::llround(m_seek_seconds * m_rate)); + } + else if(m_started && musical && t.start_position_in_quarters < m_last_quarter - 1e-6) + { + end_all(0, 0, false); + reset(); + // Exact at a constant tempo, the best guess otherwise + const double tempo = t.tempo > 0. ? t.tempo : 120.; + m_free_position = std::max( + 0, std::llround(t.start_position_in_quarters * 60. / tempo * m_rate)); + } + if(musical) + m_last_quarter = t.end_position_in_quarters; + m_started = true; + + if(m_frames <= 0) + return; + + const auto adv = inputs.advance.value; + m_grid_count = 0; + if(adv == advance_mode::every_step) + compute_grid(t); + int grid_i = 0; + + const auto run_until = [&](int64_t ts) { + while(true) + { + const int64_t gate_ts = m_gate_end >= 0 ? m_gate_end - m_now : INT64_MAX; + const int64_t grid_ts + = grid_i < m_grid_count ? m_grid[grid_i].frame : INT64_MAX; + const int64_t next = std::min(gate_ts, grid_ts); + if(next > ts || next == INT64_MAX) + break; + if(gate_ts <= grid_ts) + { + m_gate_end = -1; + end_chord(std::max(0, gate_ts)); + } + else + { + const auto& g = m_grid[grid_i++]; + if(g.index != m_index) + play_step(g.index, g.frame, g.length); + } + } + }; + + if(adv == advance_mode::on_trigger && inputs.next.value) + play_step(m_index + 1, 0, -1); + inputs.next.value.reset(); + + for(const auto& m : inputs.midi) + { + const int64_t ts = std::clamp(m.timestamp, 0, m_frames - 1); + run_until(ts); + handle(m, ts); + } + run_until(m_frames - 1); + m_now += m_frames; + m_free_position += m_frames; + } + + //! Notes sounding now, for the tests. + int sounding_notes() const noexcept + { + int n = 0; + for(auto& c : m_sounding) + for(auto v : c) + n += v > 0; + return n; + } + + //! The parsed progression in use. + const parsed_progression& progression() const noexcept { return m_prog; } + +private: + static constexpr std::size_t max_text = 256; + + struct grid_point + { + int64_t frame{}; //!< in the tick + int64_t index{}; //!< step of the timeline + int64_t length{}; //!< frames of the step, for the gate + }; + + struct held_note + { + uint8_t in_channel{}; + uint8_t in_pitch{}; + uint8_t out_channel{}; + uint8_t out_pitch{}; + bool silent{}; //!< consumed: neither its note-on nor its note-off goes out + }; + + // ------------------------------------------------------------- settings + + //! Re-parses when the choice or the custom text changes. Never allocates. + void update_progression() noexcept + { + const int choice = std::clamp(int(inputs.progression.value), 0, progression_count); + std::string_view text; + if(choice == custom_index) + { + text = inputs.custom.value; + if(text.size() > max_text) + text = text.substr(0, max_text); + } + else + { + text = progression_table[choice - 1].symbols; + } + + if(m_parsed_once && choice == m_choice + && text == std::string_view{m_text_buf.data(), m_text_size}) + return; + + m_parsed_once = true; + m_choice = choice; + std::copy(text.begin(), text.end(), m_text_buf.begin()); + m_text_size = text.size(); + text = {m_text_buf.data(), m_text_size}; + + parsed_progression p; + const auto err = parse_progression(text, p); + if(err) + { + std::array buf{}; + const auto n = format_parse_error(err, text, buf); + outputs.error.value.assign(buf.data(), n); + return; + } + outputs.error.value.clear(); + m_prog = p; + std::copy(text.begin(), text.end(), m_prog_text.begin()); + m_prog_size = text.size(); + m_family_minor + = choice != custom_index + && progression_table[choice - 1].family == progression_family::minor; + } + + std::string_view prog_text() const noexcept { return {m_prog_text.data(), m_prog_size}; } + + int key_root() const noexcept { return std::clamp(int(inputs.key.value), 0, 11); } + + mode key_scale() const noexcept + { + switch(inputs.mode.value) + { + case key_mode::automatic: + return m_family_minor ? mode::aeolian : mode::ionian; + case key_mode::major: + return mode::ionian; + case key_mode::minor: + return mode::aeolian; + case key_mode::dorian: + return mode::dorian; + case key_mode::phrygian: + return mode::phrygian; + case key_mode::lydian: + return mode::lydian; + case key_mode::mixolydian: + return mode::mixolydian; + case key_mode::locrian: + return mode::locrian; + case key_mode::harmonic_minor: + return mode::harmonic_minor; + case key_mode::melodic_minor: + return mode::melodic_minor; + } + return mode::ionian; + } + + // ----------------------------------------------------------------- grid + + /** + * Grid points of the tick, each with the step of the timeline it starts. + * Synced, the frames are those of score's quantification grid + * (get_quantification_date) and the step is computed from the musical + * position; free, steps are counted in frames from the timeline's start, + * as far as the transport tells it (m_free_position). + */ + void compute_grid(const halp::tick_musical& t) noexcept + { + const double tempo = t.tempo > 0. ? t.tempo : 120.; + const double start_q = t.start_position_in_quarters; + const double end_q = t.end_position_in_quarters; + const bool musical = end_q > start_q; + const double step_s = std::max(0.001, double(inputs.step.value)); + + if(inputs.step.sync && musical) + { + // The binding converts the note value to seconds in float: back to + // 1/1920 of a quarter, so that the grid is exact + const double q = std::max(1. / 1920., std::round(step_s * tempo / 60. * 1920.) / 1920.); + const double rate = 4. / q; + const bool valid_sig = t.signature.num > 0 && t.signature.denom > 0; + const double qib = valid_sig ? 4. * t.signature.num / t.signature.denom : 4.; + const double origin = t.last_signature_change; + const double qpf = (end_q - start_q) / m_frames; + const double frames_per_q = 60. / tempo * m_rate; + + const int64_t per_bar = int64_t(std::ceil(qib / q - 1e-6)); + + //! Step of the timeline at a musical position. + const auto index_at = [&](double pos) -> int64_t { + const double rel = pos - origin; + if(rate <= 1.) + return int64_t(std::floor(rel / (qib / rate) + 1e-6)); + const int64_t bar = int64_t(std::floor(rel / qib + 1e-6)); + const double within = rel - bar * qib; + const int64_t k = int64_t(std::floor(within / q + 1e-6)); + return bar * per_bar + std::min(k, per_bar - 1); + }; + + //! Step a grid point starts. The point's frame can be a frame off its + //! exact position (it is clamped into the tick that reports it), so the + //! bar is the one where subdivision k falls nearest that frame. + const auto point_index = [&](int f, int k) -> int64_t { + if(rate <= 1.) + return k; + const double est = start_q + f * qpf - origin; + const int64_t b0 = int64_t(std::floor(est / qib)); + int64_t best = b0; + double best_d = 1e300; + for(int64_t b = b0 - 1; b <= b0 + 1; b++) + { + const double d = std::abs(b * qib + k * q - est); + if(d < best_d) + { + best_d = d; + best = b; + } + } + return best * per_bar + std::min(k, per_bar - 1); + }; + const int64_t len = int64_t(std::llround((rate <= 1. ? qib / rate : q) * frames_per_q)); + + const auto points = t.get_quantification_date(rate); + if(m_index < 0 && (points.empty() || points.front().first != 0)) + push_grid(0, index_at(start_q), len); + for(const auto& [f, k] : points) + push_grid(f, point_index(f, k), len); + } + else + { + const int64_t step = std::max(1, std::llround(step_s * m_rate)); + const int64_t pos = m_free_position; + if(m_index < 0) + push_grid(0, pos / step, step); + int64_t next = ((pos + step - 1) / step) * step; + for(; next < pos + m_frames; next += step) + push_grid(next - pos, next / step, step); + } + } + + void push_grid(int64_t frame, int64_t index, int64_t length) noexcept + { + if(m_grid_count > 0 && m_grid[m_grid_count - 1].index == index) + return; + if(m_grid_count < int(m_grid.size())) + m_grid[m_grid_count++] = {frame, index, length}; + } + + // ----------------------------------------------------------------- steps + + //! Plays chord `step` (modulo the progression's length) at `ts`; a step + //! length >= 0 starts the gate. + void play_step(int64_t step, int64_t ts, int64_t length) noexcept + { + end_chord(ts); + m_gate_end = -1; + m_index = step; + if(m_prog.count <= 0) + return; + const int i = int(((step % m_prog.count) + m_prog.count) % m_prog.count); + const auto& sym = m_prog.chords[i]; + const auto chord = realize(sym, key_root(), key_scale()); + + if(!chord.rest) + { + m_harmony = chord; + m_has_harmony = true; + if(inputs.chords.value) + { + voicing_options o; + o.center = inputs.center.value; + o.inversion = inputs.inversion.value; + o.bass = inputs.bass.value; + o.max_notes = inputs.notes.value; + m_voicing = voice(chord, m_previous, o); + m_previous = m_voicing; + m_chord_channel = std::clamp(inputs.channel.value, 1, 16); + const int vel = std::clamp(inputs.velocity.value, 1, 127); + for(int p : m_voicing.all()) + send_on(m_chord_channel, p, vel, ts); + // Within half a percent of 1 the knob means "until the next chord" + const double gate = std::clamp(double(inputs.gate.value), 0.01, 1.); + if(length > 0 && gate < 0.995) + m_gate_end + = m_now + ts + std::max(1, std::llround(double(length) * gate)); + } + } + + // Outputs + std::array buf{}; + const bool flats = sym.accidental != 0 ? sym.accidental < 0 + : key_prefers_flats(key_root(), key_scale()); + const auto n = format_chord_name(chord, flats, buf); + assign(outputs.chord.value, {buf.data(), n}); + assign(outputs.degree.value, sym.rest ? std::string_view{"X"} : sym.text(prog_text())); + outputs.index.value = i; + outputs.root.value = chord.rest ? -1 : chord.root; + } + + static void assign(std::string& s, std::string_view v) noexcept + { + if(v.size() > max_text) + v = v.substr(0, max_text); + if(s != v) + s.assign(v.data(), v.size()); + } + + void end_chord(int64_t ts) noexcept + { + for(int p : m_voicing.all()) + send_off(m_chord_channel, p, 0, ts); + m_voicing = {}; + } + + //! The chord that input notes follow: the last one played, else the first + //! of the progression. + const realized_chord& harmony() noexcept + { + if(!m_has_harmony) + { + m_fallback = {}; + for(const auto& s : m_prog.symbols()) + if(!s.rest) + { + m_fallback = realize(s, key_root(), key_scale()); + break; + } + return m_fallback; + } + return m_harmony; + } + + // ----------------------------------------------------------------- input + + void handle(const libremidi::message& m, int64_t ts) noexcept + { + switch(classify(m)) + { + case midi_kind::note_on: + note_on(channel_of(m), m.bytes[1] & 127, m.bytes[2] & 127, ts); + return; + case midi_kind::note_off: + if(m.size() >= 3) + { + note_off(channel_of(m), m.bytes[1] & 127, m.bytes[2] & 127, ts); + return; + } + break; + case midi_kind::control_change: + if(is_all_notes_off(m)) + { + end_all(channel_of(m), ts, false); + push(m, ts); + return; + } + break; + default: + break; + } + push(m, ts); + } + + void note_on(int c, int p, int v, int64_t ts) noexcept + { + bool silent = false; + if(inputs.advance.value == advance_mode::on_note) + { + play_step(m_index + 1, ts, -1); + silent = inputs.consume.value; + } + + int out = p; + if(!silent) + { + switch(inputs.input.value) + { + case input_mode::pass: + break; + case input_mode::snap_chord: + out = nearest_chord_tone(p, harmony()); + break; + case input_mode::snap_scale: + out = nearest_scale_tone(p, key_root(), key_scale()); + break; + case input_mode::degrees: { + constexpr int white[12]{0, -1, 1, -1, 2, 3, -1, 4, -1, 5, -1, 6}; + const int w = white[p % 12]; + if(w < 0) + { + silent = true; + break; + } + const auto& h = harmony(); + if(h.rest) + { + silent = true; + break; + } + const int root_pitch = 60 + ((h.root + 6) % 12) - 6; + out = chord_tone(h, root_pitch, (p / 12) * 7 + w - 35); + if(out < 0 || out > 127) + silent = true; + break; + } + } + } + + if(m_held_count == max_held) + release_held(0, ts, 0); + held_note& h = m_held[m_held_count++]; + h = {uint8_t(c), uint8_t(p), uint8_t(c), uint8_t(std::clamp(out, 0, 127)), silent}; + if(!silent) + send_on(c, h.out_pitch, v, ts); + } + + //! A note-off that matches no note-on seen is dropped: every note this + //! object sends is tracked, so such a note-off could only end one of them + //! (a chord note) or end nothing. + void note_off(int c, int p, int vel, int64_t ts) noexcept + { + for(int i = 0; i < m_held_count; i++) + if(m_held[i].in_channel == c && m_held[i].in_pitch == p) + { + release_held(i, ts, vel); + return; + } + } + + void release_held(int i, int64_t ts, int vel) noexcept + { + const held_note h = m_held[i]; + if(!h.silent) + send_off(h.out_channel, h.out_pitch, vel, ts); + for(int k = i; k + 1 < m_held_count; k++) + m_held[k] = m_held[k + 1]; + m_held_count--; + } + + // ---------------------------------------------------------------- output + + void send_on(int c, int p, int v, int64_t ts) noexcept + { + auto& n = m_sounding[(c - 1) & 15][p & 127]; + if(n > 0) + push(libremidi::channel_events::note_off(c, p, 0), ts); + if(n < 0xFFFF) + n++; + push(libremidi::channel_events::note_on(c, p, v), ts); + } + + void send_off(int c, int p, int v, int64_t ts) noexcept + { + auto& n = m_sounding[(c - 1) & 15][p & 127]; + if(n == 0) + return; + if(--n == 0) + push(libremidi::channel_events::note_off(c, p, v), ts); + } + + void push(libremidi::message m, int64_t ts) noexcept + { + m.timestamp = ts; + outputs.midi.push_back(std::move(m)); + } + + //! Ends what sounds, on one channel or (0) all. The held input notes are + //! forgotten, or kept silent so that their note-offs do not go out alone. + void end_all(int channel, int64_t ts, bool forget) noexcept + { + for(int c = 1; c <= 16; c++) + { + if(channel != 0 && c != channel) + continue; + for(int p = 0; p < 128; p++) + { + auto& n = m_sounding[c - 1][p]; + if(n > 0) + { + n = 0; + push(libremidi::channel_events::note_off(c, p, 0), ts); + } + } + } + if(channel == 0 || channel == m_chord_channel) + { + m_voicing = {}; + m_gate_end = -1; + } + if(forget) + { + m_held_count = 0; + return; + } + for(int i = 0; i < m_held_count; i++) + if(channel == 0 || m_held[i].out_channel == channel) + m_held[i].silent = true; + } + + //! Back to before the first step: the next one starts the progression. + void reset() noexcept + { + m_index = -1; + m_previous = {}; + m_voicing = {}; + m_gate_end = -1; + m_has_harmony = false; + m_started = false; + m_last_quarter = -1e300; + } + + parsed_progression m_prog{}; + std::array m_prog_text{}; + std::size_t m_prog_size{}; + std::array m_text_buf{}; + std::size_t m_text_size{}; + int m_choice{-1}; + bool m_parsed_once{}; + bool m_family_minor{}; + + std::array m_grid{}; + int m_grid_count{}; + + int64_t m_index{-1}; + realized_chord m_harmony{}; + realized_chord m_fallback{}; + bool m_has_harmony{}; + voicing m_voicing{}; + voicing m_previous{}; + int m_chord_channel{1}; + int64_t m_gate_end{-1}; + + std::array m_held{}; + int m_held_count{}; + std::array, 16> m_sounding{}; + + int64_t m_now{}; + //! Frames from the start of the timeline, for the free steps: from 0 at + //! each start, moved by seeks. + int64_t m_free_position{}; + double m_last_quarter{-1e300}; + double m_rate{48000.}; + double m_seek_seconds{}; + int m_frames{}; + bool m_started{}; + bool m_stop_requested{}; + bool m_seek_requested{}; +}; +} diff --git a/examples/Advanced/MidiScaler/MidiRandomSequencer.hpp b/examples/Advanced/MidiScaler/MidiRandomSequencer.hpp new file mode 100644 index 000000000..f30b8491a --- /dev/null +++ b/examples/Advanced/MidiScaler/MidiRandomSequencer.hpp @@ -0,0 +1,1216 @@ +#pragma once + +/* SPDX-License-Identifier: GPL-3.0-or-later */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mtk +{ +/** + * @brief A clocked random melody generator with two engines, after two + * random-voltage Eurorack modules, translated to MIDI. + * + * Turing: after the Turing Machine by Tom Whitwell (Music Thing Modular, open + * hardware, CC-BY-SA; https://musicthing.co.uk/pages/turing.html). A 16-bit + * shift register turns once per step; the bit leaving position Length comes + * back in at the bottom. Lock decides what happens to it: at 1 it is kept (a + * locked loop of Length steps), at 0 it is replaced by a random bit (every + * step new), in between it is replaced with probability 1 - Lock (the loop + * mutates slowly). Below 0, like the big knob turned left of noon, it is + * inverted with probability -Lock instead: at -1 the loop plays itself and + * then its inverse, a locked loop twice as long. Write forces the incoming + * bit. The lower 8 bits are the step's value (the module's DAC), and bits 1 + * to 4 can drive the gate, as on the Pulses expander. + * + * Marbles: after the X and T sections of Marbles by Emilie Gillet (Mutable + * Instruments, firmware under the MIT licence; + * https://pichenettes.github.io/mutable-instruments-documentation/modules/marbles/). + * X draws a value per step: Spread 0 is the constant Bias, larger spreads + * draw further from it, 1 covering the whole range (skewed towards Bias, like + * Marbles' beta-shaped distributions). Deja vu is the probability of replaying + * the draw made Length steps earlier instead of a new one: 0 always new, 1 a + * locked loop. The loop stores the raw draws, so Spread, Bias and Steps still + * shape a locked loop. The T section is reduced to a gate per step: Density + * is the probability a step plays, Regularity moves from independent coin + * tosses (0) to the same density laid out evenly over the loop (1). + * + * Both engines share the output stage. The value picks a note among the notes + * of the scale within Range, from low to high. Steps above 0 keeps only the + * strongest degrees (1: root and fifth), like Marbles' Steps knob past noon. + * Octave jump adds up to that many octaves at random. Jitter delays a note by + * up to half a step: it never plays early, so there is no added latency. + * + * Every random choice of a step comes from the engine's state (the register, + * or the loop memory), so a locked loop repeats its pitches, gates, + * velocities, accents and jitter exactly. + * + * The clock is the Rate grid (on the musical grid from the start of the + * timeline when synced), the note-ons of the input (consumed with their + * note-offs), or only the Step input. A Step impulse adds a step in any mode. + * With a Seed, the same transport gives the same take; a seek, a restart + * of the transport or Reset starts it again. + */ +struct MidiRandomSequencer +{ + halp_meta(name, "Midi random sequencer") + halp_meta(c_name, "midi_random_sequencer") + halp_meta(category, "Midi/Generators") + halp_meta(author, "ossia score") + halp_meta( + description, "Random melodies from a shift register (Turing Machine) or a " + "random distribution with loop memory (Marbles)") + halp_meta(manual_url, "https://ossia.io/score-docs/processes/midi-random-sequencer.html") + halp_meta(uuid, "1fbb46dd-c422-40aa-8d0b-b9237ba91aa8") + halp_flag(process_exec); + + static constexpr int max_length = 16; + static constexpr int max_steps_per_tick = 64; + + enum Engine + { + Turing, + Marbles + }; + + enum Write + { + Off, + Ones, + Zeros + }; + + struct clock_control + { + static clang_buggy_consteval auto name() { return std::string_view{"Clock"}; } + halp_meta( + description, + "Rate: a step every Rate. Input notes: each incoming note-on is a step " + "(the input notes are consumed). Step only: only the Step input advances.") + enum enum_type + { + rate, + input_notes, + step_only + } value{rate}; + enum widget + { + combobox + }; + struct range + { + std::string_view values[3]{"Rate", "Input notes", "Step only"}; + enum_type init = rate; + }; + operator enum_type&() noexcept { return value; } + operator const enum_type&() const noexcept { return value; } + auto& operator=(enum_type t) noexcept + { + value = t; + return *this; + } + }; + + struct gate_control + { + static clang_buggy_consteval auto name() { return std::string_view{"Gate"}; } + halp_meta( + description, + "Which steps play in Turing mode: every step, the steps where a register " + "bit is set (like the Pulses expander), or Density as in Marbles mode") + enum enum_type + { + every, + bit1, + bit2, + bit3, + bit4, + bit1_or_2, + bit1_and_2, + density + } value{every}; + enum widget + { + combobox + }; + struct range + { + std::string_view values[8]{"Every step", "Bit 1", "Bit 2", "Bit 3", + "Bit 4", "Bit 1 or 2", "Bit 1 and 2", "Density"}; + enum_type init = every; + }; + operator enum_type&() noexcept { return value; } + operator const enum_type&() const noexcept { return value; } + auto& operator=(enum_type t) noexcept + { + value = t; + return *this; + } + }; + + struct root_control + { + static clang_buggy_consteval auto name() { return std::string_view{"Root"}; } + halp_meta(description, "Tonic of the scale") + enum enum_type + { + c, + cs, + d, + ds, + e, + f, + fs, + g, + gs, + a, + as, + b + } value{c}; + enum widget + { + combobox + }; + struct range + { + std::string_view values[12]{"C", "C#", "D", "D#", "E", "F", + "F#", "G", "G#", "A", "A#", "B"}; + enum_type init = c; + }; + operator enum_type&() noexcept { return value; } + operator const enum_type&() const noexcept { return value; } + auto& operator=(enum_type t) noexcept + { + value = t; + return *this; + } + }; + + struct scale_control + { + static clang_buggy_consteval auto name() { return std::string_view{"Scale"}; } + halp_meta(description, "Notes the sequence is quantized to") + enum enum_type + { + major, + dorian, + phrygian, + lydian, + mixolydian, + minor, + locrian, + harmonic_minor, + melodic_minor, + major_pentatonic, + minor_pentatonic, + chromatic + } value{major}; + enum widget + { + combobox + }; + struct range + { + std::string_view values[12]{ + "Major", "Dorian", "Phrygian", "Lydian", + "Mixolydian", "Minor", "Locrian", "Harmonic minor", + "Melodic minor", "Major pentatonic", "Minor pentatonic", "Chromatic"}; + enum_type init = major; + }; + operator enum_type&() noexcept { return value; } + operator const enum_type&() const noexcept { return value; } + auto& operator=(enum_type t) noexcept + { + value = t; + return *this; + } + }; + + struct ins + { + halp::midi_bus<"MIDI", libremidi::message> midi; + + struct : halp::enum_t + { + enum widget + { + combobox + }; + halp_meta( + description, "Turing: a looping shift register. Marbles: random draws " + "with a loop memory.") + } engine; + + clock_control clock; + + struct : halp::time_chooser<"Rate", halp::range{0.005, 4., 0.125}> + { + halp_meta( + description, "Length of a step; when synced, the steps follow the " + "musical grid from the start of the timeline") + } rate; + + struct : halp::impulse_button<"Step"> + { + halp_meta(description, "Plays one step now, in any clock mode") + } step; + + struct : halp::impulse_button<"Reset"> + { + halp_meta( + description, "Starts the take again: with a seed the same sequence, " + "without a new one") + } reset; + + struct : halp::knob_f32<"Swing", halp::range{0., 1., 0.}> + { + halp_meta( + description, "Delays every second step: 0.5 puts it on the triplet") + } swing; + + struct : halp::spinbox_i32<"Length", halp::irange{1, max_length, 8}> + { + halp_meta( + description, "Steps in the loop: the register length in Turing mode, " + "the loop memory in Marbles mode") + } length; + + struct : halp::knob_f32<"Lock", halp::range{-1., 1., 0.9}> + { + halp_meta( + description, + "1: a locked loop. 0: every step random. In between the loop mutates, " + "slower towards 1. Below 0 the bits get inverted: -1 is a locked loop " + "followed by its inverse, twice as long.") + } lock; + + struct : halp::enum_t + { + enum widget + { + combobox + }; + halp_meta( + description, "Forces the bit entering the register: Ones raises the " + "values, Zeros lowers them") + } write; + + gate_control gate; + + struct : halp::knob_f32<"Spread", halp::range{0., 1., 0.5}> + { + halp_meta( + description, "0: always the Bias value. 1: anywhere in the range, " + "most often near Bias.") + } spread; + + struct : halp::knob_f32<"Bias", halp::range{0., 1., 0.5}> + { + halp_meta(description, "Centre of the random values: low to high notes") + } bias; + + struct : halp::knob_f32<"Deja vu", halp::range{0., 1., 0.}> + { + halp_meta( + description, "Probability of replaying the step of the previous loop " + "instead of a new draw: 1 is a locked loop") + } deja_vu; + + struct : halp::knob_f32<"Density", halp::range{0., 1., 1.}> + { + halp_meta(description, "Probability that a step plays a note") + } density; + + struct : halp::knob_f32<"Regularity", halp::range{0., 1., 0.}> + { + halp_meta( + description, "0: each step tosses a coin. 1: the same number of notes " + "spread evenly over the loop.") + } regularity; + + struct : halp::knob_f32<"Jitter", halp::range{0., 1., 0.}> + { + halp_meta( + description, "Random delay of each note, up to half a step at 1; notes " + "never play early") + } jitter; + + root_control root; + scale_control key_mode; + + struct : halp::knob_f32<"Steps", halp::range{0., 1., 0.}> + { + halp_meta( + description, "0: any note of the scale. Towards 1 only the strongest " + "degrees remain: at 1 the root and the fifth.") + } steps; + + struct : halp::range_slider_f32< + "Range", halp::range_slider_range{0., 127., {48., 72.}}> + { + halp_meta(description, "Lowest and highest notes, as MIDI note numbers") + } note_range; + + struct : halp::spinbox_i32<"Octave jump", halp::irange{0, 4, 0}> + { + halp_meta(description, "Each note may jump up by as many octaves, at random") + } octaves; + + struct : halp::knob_f32<"Gate length", halp::range{0.01, 2., 0.5}> + { + halp_meta( + description, "Length of a note as a proportion of a step; above 1 the " + "notes overlap") + } gate_length; + + struct : halp::range_slider_f32< + "Velocity", halp::range_slider_range{1., 127., {80., 110.}}> + { + halp_meta(description, "Velocities are drawn within this range") + } velocity; + + struct : halp::toggle<"Accent"> + { + halp_meta( + description, + "Accented steps (register bit 5 in Turing mode, one step in four on " + "average in Marbles mode) play at the highest velocity, the others in " + "the lower half of the range") + } accent; + + struct : halp::spinbox_i32<"Channel", halp::irange{1, 16, 1}> + { + halp_meta(description, "MIDI channel of the notes") + } channel; + + struct : halp::spinbox_i32<"Seed", halp::irange{0, 10000, 0}> + { + halp_meta( + description, "0 runs freely; any other value gives a reproducible take, " + "restarted when the transport rewinds") + } seed; + } inputs; + + struct + { + halp::midi_bus<"MIDI", libremidi::message> midi; + + struct : halp::accurate> + { + halp_meta( + description, "Value of each step, 0 to 1, before quantization: the " + "register in Turing mode, X in Marbles mode") + } value; + + struct : halp::accurate> + { + halp_meta(description, "True while a note of the sequence sounds") + } gate; + } outputs; + + struct ui + { + halp_meta(name, "Midi random sequencer") + halp_meta(layout, halp::layouts::hbox) + halp_meta(background, halp::colors::background_dark) + + struct + { + halp_meta(name, "Clock") + halp_meta(layout, halp::layouts::section) + halp_meta(background, halp::colors::background_mid) + struct + { + halp_meta(layout, halp::layouts::hbox) + halp::item<&ins::engine> engine; + halp::item<&ins::clock> clock; + } source; + struct + { + halp_meta(layout, halp::layouts::hbox) + halp::item<&ins::rate> rate; + struct + { + halp_meta(layout, halp::layouts::vbox) + static constexpr auto condition() + { + return halp::enabled_when<&ins::clock, clock_control::rate>{}; + } + halp::item<&ins::swing> swing; + } swing; + halp::item<&ins::length> length; + } steps; + struct + { + halp_meta(layout, halp::layouts::hbox) + halp::item<&ins::step> step; + halp::item<&ins::reset> reset; + halp::item<&ins::seed> seed; + } buttons; + } clock; + + struct + { + halp_meta(name, "Settings") + halp_meta(layout, halp::layouts::tabs) + halp_meta(background, halp::colors::background_darker) + + struct + { + halp_meta(name, "Turing") + halp_meta(layout, halp::layouts::hbox) + static constexpr auto condition() + { + return halp::enabled_when<&ins::engine, Turing>{}; + } + halp::item<&ins::lock> lock; + halp::item<&ins::write> write; + halp::item<&ins::gate> gate; + } turing; + + struct + { + halp_meta(name, "Marbles") + halp_meta(layout, halp::layouts::hbox) + static constexpr auto condition() + { + return halp::enabled_when<&ins::engine, Marbles>{}; + } + halp::item<&ins::spread> spread; + halp::item<&ins::bias> bias; + halp::item<&ins::deja_vu> deja_vu; + } marbles; + + struct + { + halp_meta(name, "Rhythm") + halp_meta(layout, halp::layouts::hbox) + halp::item<&ins::density> density; + halp::item<&ins::regularity> regularity; + halp::item<&ins::jitter> jitter; + } rhythm; + + struct + { + halp_meta(name, "Pitch") + halp_meta(layout, halp::layouts::vbox) + struct + { + halp_meta(layout, halp::layouts::hbox) + halp::item<&ins::root> root; + halp::item<&ins::key_mode> key_mode; + halp::item<&ins::steps> steps; + } key; + struct + { + halp_meta(layout, halp::layouts::hbox) + halp::item<&ins::note_range> note_range; + halp::item<&ins::octaves> octaves; + } span; + } pitch; + + struct + { + halp_meta(name, "Notes") + halp_meta(layout, halp::layouts::vbox) + struct + { + halp_meta(layout, halp::layouts::hbox) + halp::item<&ins::gate_length> gate_length; + halp::item<&ins::accent> accent; + halp::item<&ins::channel> channel; + } shape; + halp::item<&ins::velocity> velocity; + } notes; + } settings; + }; + + //! std::random_device may allocate, block or throw: it is read here, once, + //! and never on the audio thread. + MidiRandomSequencer() + { + std::random_device rd; + m_rng.state = (uint64_t(rd()) << 32) ^ uint64_t(rd()); + init_memory(); + } + + void prepare(halp::setup s) noexcept + { + if(s.rate > 0.) + m_rate = s.rate; + } + + //! The next tick, possibly of 0 frames, ends every note. + void stop() noexcept { m_stop_requested = true; } + + //! The host moved the transport: the next tick starts the grid over. + template + void transport(std::chrono::duration) noexcept + { + m_seek_requested = true; + } + + using tick = halp::tick_musical; + void operator()(const halp::tick_musical& t) noexcept + { + const int frames = t.frames > 0 ? t.frames : 0; + m_frames = frames; + read_parameters(); + + if(m_stop_requested) + { + m_stop_requested = false; + end_all(0, 0); + m_held = {}; + m_running = false; + } + + if(frames <= 0) + return; + + // position_in_frames only ever grows: a seek is told by transport(), or + // seen as the musical position going back + const bool sought = std::exchange(m_seek_requested, false); + if(!m_running) + { + m_running = true; + restart_take(); + } + else if(sought || t.start_position_in_quarters < m_last_end_quarter - 1e-6) + { + // The grid starts again, and a seeded take with it + if(m_seed != 0) + { + end_all(0, 0); + restart_take(); + } + else + { + restart_clock(); + } + } + m_last_end_quarter = t.end_position_in_quarters; + + if(inputs.reset.value) + restart_take(); + + plan_steps(t); + + for(const auto& m : inputs.midi) + { + const int ts = std::clamp(int(m.timestamp), 0, frames - 1); + advance(ts); + handle(m, ts); + } + advance(frames); + m_now += frames; + } + + //! Notes of the sequence sounding now. + int sounding() const noexcept { return m_total; } + + //! The register, for tests and displays. + uint16_t register_bits() const noexcept { return m_register; } + +private: + // ---------------------------------------------------------------- random + + //! SplitMix64: one 64-bit state, no allocation, the same on every platform. + struct rng + { + uint64_t state{}; + uint64_t next() noexcept + { + uint64_t z = (state += 0x9E3779B97F4A7C15ull); + z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9ull; + z = (z ^ (z >> 27)) * 0x94D049BB133111EBull; + return z ^ (z >> 31); + } + double uniform() noexcept { return double(next() >> 11) * 0x1.0p-53; } + }; + + static double hash01(uint64_t v) noexcept + { + rng r{v * 0xD1B54A32D192ED03ull}; + return r.uniform(); + } + + //! The random parts of a step, as uniform draws in [0, 1). + struct draws + { + double x{}; + double gate{}; + double jitter{}; + double velocity{}; + double accent{}; + double octave{}; + }; + + draws fresh_draws() noexcept + { + draws d; + d.x = m_rng.uniform(); + d.gate = m_rng.uniform(); + d.jitter = m_rng.uniform(); + d.velocity = m_rng.uniform(); + d.accent = m_rng.uniform(); + d.octave = m_rng.uniform(); + return d; + } + + void init_memory() noexcept + { + for(auto& d : m_memory) + d = fresh_draws(); + // A register whose bits repeat with the loop length: a locked loop is + // periodic from its first step + const int len = std::clamp(m_length, 1, max_length); + uint16_t pattern = 0; + for(int i = 0; i < len; i++) + if(m_rng.uniform() < 0.5) + pattern |= uint16_t(1u << i); + m_register = 0; + for(int i = 0; i < 16; i++) + if(pattern & (1u << (i % len))) + m_register |= uint16_t(1u << i); + } + + void restart_clock() noexcept + { + m_last_q = -std::numeric_limits::infinity(); + m_free_next = -1.; + m_free_index = 0; + } + + void restart_take() noexcept + { + if(m_seed != 0) + m_rng.state = uint64_t(m_seed) * 0x2545F4914F6CDD1Dull; + m_step = 0; + init_memory(); + restart_clock(); + } + + // ---------------------------------------------------------------- engines + + struct step_result + { + double value{}; + bool gate{}; + double jitter{}; + double velocity{}; + bool accent{}; + double octave{}; + }; + + bool density_gate(double draw, int pos) const noexcept + { + const int pulses = int(std::lround(m_density * m_length)); + const bool even = euclid_hit(pos, m_length, pulses, 0); + const double r = m_regularity; + return draw * (1. - r) + (even ? 0. : 1.) * r < m_density; + } + + step_result turing_step() noexcept + { + const double change = m_rng.uniform(); + const double coin = m_rng.uniform(); + const int len = m_length; + const bool out = (m_register >> (len - 1)) & 1u; + bool in{}; + switch(m_write) + { + case Ones: + in = true; + break; + case Zeros: + in = false; + break; + default: + if(m_lock >= 0.) + in = change < 1. - m_lock ? coin < 0.5 : out; + else + in = change < 1. + m_lock ? coin < 0.5 : !out; + break; + } + m_register = uint16_t((m_register << 1) | (in ? 1u : 0u)); + + const uint16_t r = m_register; + auto bit = [r](int n) { return ((r >> (n - 1)) & 1u) != 0; }; + step_result s; + s.value = double(r & 0xFF) / 255.; + s.jitter = hash01(uint64_t(r) | (1ull << 16)); + s.velocity = hash01(uint64_t(r) | (2ull << 16)); + s.octave = hash01(uint64_t(r) | (3ull << 16)); + s.accent = bit(5); + switch(m_gate) + { + case gate_control::every: + s.gate = true; + break; + case gate_control::bit1: + case gate_control::bit2: + case gate_control::bit3: + case gate_control::bit4: + s.gate = bit(int(m_gate) - int(gate_control::bit1) + 1); + break; + case gate_control::bit1_or_2: + s.gate = bit(1) || bit(2); + break; + case gate_control::bit1_and_2: + s.gate = bit(1) && bit(2); + break; + case gate_control::density: + s.gate = density_gate(hash01(uint64_t(r) | (4ull << 16)), int(m_step % len)); + break; + } + return s; + } + + //! Spread and Bias shape a uniform draw: Spread scales the distance to + //! Bias, and a small spread also pulls the draws towards it. + double shape_x(double u) const noexcept + { + const double c = m_bias; + const double s = m_spread; + if(s <= 0.) + return c; + const double v = 2. * u - 1.; + const double p = 1. + 3. * (1. - s); + const double m = std::pow(std::abs(v), p) * s; + return v >= 0. ? c + m * (1. - c) : c - m * c; + } + + step_result marbles_step() noexcept + { + const double replay = m_rng.uniform(); + const draws fresh = fresh_draws(); + const int pos = int(m_step % m_length); + draws& slot = m_memory[pos]; + if(!(replay < m_deja_vu)) + slot = fresh; + + step_result s; + s.value = std::clamp(shape_x(slot.x), 0., 1.); + s.gate = density_gate(slot.gate, pos); + s.jitter = slot.jitter; + s.velocity = slot.velocity; + s.accent = slot.accent < 0.25; + s.octave = slot.octave; + return s; + } + + // ------------------------------------------------------------------ pitch + + //! The notes of the scale within the range that pass the Steps filter. + void build_notes() noexcept + { + // How strong a degree is, by semitones above the root, for the scales + // that are not seven-note modes + static constexpr double semitone_weight[12] + = {1.0, 0.1, 0.3, 0.7, 0.7, 0.4, 0.1, 0.9, 0.2, 0.2, 0.5, 0.5}; + // By degree for the modes: root, fifth, third, seventh, fourth, second, sixth + static constexpr double degree_weight[7] = {1.0, 0.3, 0.7, 0.4, 0.9, 0.2, 0.5}; + + std::array weight{}; + weight.fill(-1.); + const int k = int(m_scale); + if(k <= int(scale_control::melodic_minor)) + { + for(int d = 0; d < 7; d++) + weight[mode_steps[k][d]] = degree_weight[d]; + } + else if(k == scale_control::major_pentatonic) + { + for(int st : {0, 2, 4, 7, 9}) + weight[st] = semitone_weight[st]; + } + else if(k == scale_control::minor_pentatonic) + { + for(int st : {0, 3, 5, 7, 10}) + weight[st] = semitone_weight[st]; + } + else + { + for(int st = 0; st < 12; st++) + weight[st] = semitone_weight[st]; + } + + const double threshold = m_steps * 0.9 - 1e-9; + auto collect = [&](double min_weight) { + m_note_count = 0; + for(int n = m_low; n <= m_high; n++) + { + const double w = weight[(n - m_root + 120) % 12]; + if(w >= 0. && w >= min_weight) + m_notes[m_note_count++] = uint8_t(n); + } + }; + collect(threshold); + // A range too narrow for the filter falls back to the scale, then to + // every note + if(m_note_count == 0) + collect(0.); + if(m_note_count == 0) + { + for(int n = m_low; n <= m_high; n++) + m_notes[m_note_count++] = uint8_t(n); + } + } + + int pitch_of(const step_result& s) const noexcept + { + int idx = int(s.value * m_note_count); + idx = std::clamp(idx, 0, m_note_count - 1); + int p = m_notes[idx]; + const int jump = std::min(int(s.octave * (m_octaves + 1)), m_octaves); + p += 12 * jump; + while(p > 127) + p -= 12; + return p; + } + + int velocity_of(const step_result& s) const noexcept + { + double lo = m_vel_lo, hi = m_vel_hi; + if(m_accent) + { + if(s.accent) + return int(std::lround(hi)); + hi = lo + (hi - lo) * 0.5; + } + return std::clamp(int(std::lround(lo + s.velocity * (hi - lo))), 1, 127); + } + + // ------------------------------------------------------------------- clock + + void read_parameters() noexcept + { + m_engine = inputs.engine.value; + m_clock = inputs.clock.value; + m_seed = std::clamp(inputs.seed.value, 0, 10000); + m_length = std::clamp(inputs.length.value, 1, max_length); + m_lock = std::clamp(double(inputs.lock.value), -1., 1.); + m_write = inputs.write.value; + m_gate = inputs.gate.value; + m_spread = std::clamp(double(inputs.spread.value), 0., 1.); + m_bias = std::clamp(double(inputs.bias.value), 0., 1.); + m_deja_vu = std::clamp(double(inputs.deja_vu.value), 0., 1.); + m_density = std::clamp(double(inputs.density.value), 0., 1.); + m_regularity = std::clamp(double(inputs.regularity.value), 0., 1.); + m_jitter = std::clamp(double(inputs.jitter.value), 0., 1.); + m_swing = std::clamp(double(inputs.swing.value), 0., 1.); + m_root = std::clamp(int(inputs.root.value), 0, 11); + m_scale = inputs.key_mode.value; + m_steps = std::clamp(double(inputs.steps.value), 0., 1.); + m_low = std::clamp(int(std::lround(inputs.note_range.value.start)), 0, 127); + m_high = std::clamp(int(std::lround(inputs.note_range.value.end)), 0, 127); + if(m_low > m_high) + std::swap(m_low, m_high); + m_octaves = std::clamp(inputs.octaves.value, 0, 4); + m_gate_length = std::clamp(double(inputs.gate_length.value), 0.01, 2.); + m_vel_lo = std::clamp(double(inputs.velocity.value.start), 1., 127.); + m_vel_hi = std::clamp(double(inputs.velocity.value.end), 1., 127.); + if(m_vel_lo > m_vel_hi) + std::swap(m_vel_lo, m_vel_hi); + m_accent = inputs.accent.value; + m_channel = std::clamp(inputs.channel.value, 1, 16); + m_step_frames = std::max(1., double(inputs.rate.value) * m_rate); + build_notes(); + } + + void plan(int offset, int64_t index) noexcept + { + if(m_plan_count < max_steps_per_tick) + m_plan[m_plan_count++] = {offset, index}; + } + + //! The steps of this tick, in order, as frame offsets. + void plan_steps(const halp::tick_musical& t) noexcept + { + m_plan_count = 0; + m_plan_next = 0; + + if(inputs.step.value) + plan(0, m_manual_index++); + + if(m_clock != clock_control::rate) + return; + + const int frames = m_frames; + const double s = t.start_position_in_quarters; + const double e = t.end_position_in_quarters; + const double tempo = t.tempo > 0. ? t.tempo : 120.; + if(inputs.rate.sync && e > s) + { + // The step in quarters, rounded to 1/1920 of a quarter so that the + // seconds the host hands over land back on the exact note value + const double len_q + = std::round(double(inputs.rate.value) * tempo / 60. * 1920.) / 1920.; + if(len_q <= 0.) + return; + constexpr double eps = 1e-9; + int64_t k = int64_t(std::ceil(s / len_q - eps)); + for(; double(k) * len_q < e - eps; k++) + { + const double q = double(k) * len_q; + if(q <= m_last_q + eps) + continue; + m_last_q = q; + const int off = std::clamp( + int(std::llround((q - s) / (e - s) * frames)), 0, frames - 1); + plan(off, k); + if(m_plan_count == max_steps_per_tick) + break; + } + m_free_next = -1.; + return; + } + + if(m_free_next < 0.) + m_free_next = double(m_now); + while(m_free_next < double(m_now + frames) && m_plan_count < max_steps_per_tick) + { + const int off + = std::clamp(int(std::floor(m_free_next - double(m_now))), 0, frames - 1); + plan(off, m_free_index++); + m_free_next += m_step_frames; + } + // A step too short for the tick drops the excess instead of piling up + if(m_free_next < double(m_now + frames)) + m_free_next = double(m_now + frames); + } + + //! Plays the planned steps before `ts`, then sends what is due before it. + void advance(int ts) noexcept + { + while(m_plan_next < m_plan_count && m_plan[m_plan_next].offset < ts) + { + const auto& p = m_plan[m_plan_next++]; + fire_step(p.offset, p.index, m_clock == clock_control::rate); + } + m_queue.release(m_now, ts, [this](libremidi::message&& m, uint32_t tag) { + send(std::move(m), tag); + }); + } + + void fire_step(int offset, int64_t index, bool swung) noexcept + { + const step_result s = m_engine == Marbles ? marbles_step() : turing_step(); + m_step++; + + const double sw = swung ? swing_delay(index, m_swing) : 0.; + const int64_t at = m_now + offset + int64_t(std::llround(sw * m_step_frames)); + + uint32_t bits = std::bit_cast(float(s.value)); + m_queue.schedule(at, value_marker(), bits); + + if(!s.gate) + return; + const int64_t on_at + = at + int64_t(std::llround(s.jitter * m_jitter * 0.5 * m_step_frames)); + const int64_t len + = std::max(1, std::llround(m_step_frames * m_gate_length)); + const int p = pitch_of(s); + const int v = velocity_of(s); + m_queue.schedule(on_at, libremidi::channel_events::note_on(m_channel, p, v)); + m_queue.schedule(on_at + len, libremidi::channel_events::note_off(m_channel, p, 0)); + } + + //! A placeholder in the queue for a change of the Value output, carried in + //! the tag. 0xF4 is undefined in MIDI 1 and never leaves the object. + static libremidi::message value_marker() noexcept { return libremidi::message{0xF4}; } + + // ----------------------------------------------------------------- output + + void push(libremidi::message m, int ts) noexcept + { + m.timestamp = ts; + outputs.midi.midi_messages.push_back(std::move(m)); + } + + template + static void write_timed(Port& port, int ts, V v) noexcept + { + // The map keeps 16 entries without allocating. Once full, the last entry + // takes the latest value, so that the port ends the tick where the + // sequence does: a gate left open would hold forever. + auto& values = port.values; + if(values.size() < 16 || values.find(ts) != values.end()) + values[ts] = v; + else if(!values.empty()) + std::prev(values.end())->second = v; + } + + void send(libremidi::message&& m, uint32_t tag) noexcept + { + const int ts = int(m.timestamp); + if(m.size() == 1 && m.bytes[0] == 0xF4) + { + write_timed(outputs.value, ts, std::bit_cast(tag)); + return; + } + const int c = channel_of(m); + const int p = m.bytes[1] & 127; + auto& n = m_sounding[(c - 1) & 15][p]; + if(is_note_on(m)) + { + // Overlapping notes of one pitch: the new one re-attacks, the pitch + // ends with the last of them + if(n > 0) + push(libremidi::channel_events::note_off(c, p, 0), ts); + else + m_total++; + n++; + push(std::move(m), ts); + write_timed(outputs.gate, ts, true); + } + else if(is_note_off(m)) + { + if(n == 0) + return; + if(--n == 0) + { + m_total--; + push(std::move(m), ts); + if(m_total == 0) + write_timed(outputs.gate, ts, false); + } + } + } + + //! Ends what sounds and drops the queued notes, on one channel or (0) all. + void end_all(int channel, int ts) noexcept + { + for(int c = 1; c <= 16; c++) + { + if(channel != 0 && c != channel) + continue; + for(int p = 0; p < 128; p++) + { + auto& n = m_sounding[c - 1][p]; + if(n > 0) + { + n = 0; + m_total--; + push(libremidi::channel_events::note_off(c, p, 0), ts); + } + } + } + m_queue.remove_if([&](const auto& e) { + const int ch = channel_of(e.msg); + return channel == 0 || (ch != 0 && ch == channel); + }); + if(m_total == 0) + write_timed(outputs.gate, ts, false); + } + + // ------------------------------------------------------------------ input + + void handle(const libremidi::message& m, int ts) noexcept + { + switch(classify(m)) + { + case midi_kind::note_on: + if(m_clock == clock_control::input_notes) + { + m_held[(channel_of(m) - 1) & 15][m.bytes[1] & 127] = true; + fire_step(ts, m_input_index++, false); + return; + } + break; + case midi_kind::note_off: { + auto& h = m_held[(channel_of(m) - 1) & 15][m.bytes[1] & 127]; + if(h) + { + h = false; + return; + } + break; + } + case midi_kind::control_change: + if(is_all_notes_off(m)) + end_all(channel_of(m), ts); + break; + default: + break; + } + push(m, ts); + } + + struct planned + { + int offset{}; + int64_t index{}; + }; + + std::array m_memory{}; + std::array m_notes{}; + std::array m_plan{}; + std::array, 16> m_sounding{}; + std::array, 16> m_held{}; + event_queue<1024> m_queue; + + rng m_rng{}; + + double m_rate{48000.}; + double m_step_frames{6000.}; + double m_last_q{-std::numeric_limits::infinity()}; + double m_free_next{-1.}; + int64_t m_now{}; + double m_last_end_quarter{}; + int64_t m_step{}; + int64_t m_free_index{}; + int64_t m_manual_index{}; + int64_t m_input_index{}; + int m_frames{}; + int m_plan_count{}; + int m_plan_next{}; + int m_note_count{}; + int m_total{}; + bool m_stop_requested{}; + bool m_seek_requested{}; + bool m_running{}; + + // Parameters, read at the start of each tick + Engine m_engine{Turing}; + clock_control::enum_type m_clock{clock_control::rate}; + Write m_write{Off}; + gate_control::enum_type m_gate{gate_control::every}; + scale_control::enum_type m_scale{scale_control::major}; + uint16_t m_register{}; + int m_seed{}; + int m_length{8}; + int m_root{}; + int m_low{48}, m_high{72}; + int m_octaves{}; + double m_gate_length{0.5}; + int m_channel{1}; + double m_lock{0.9}; + double m_spread{0.5}, m_bias{0.5}, m_deja_vu{}; + double m_density{1.}, m_regularity{}, m_jitter{}, m_swing{}; + double m_steps{}; + double m_vel_lo{80.}, m_vel_hi{110.}; + bool m_accent{}; +}; +} diff --git a/examples/Advanced/MidiScaler/MidiReader.hpp b/examples/Advanced/MidiScaler/MidiReader.hpp index 6e168da2a..18cae5ffc 100644 --- a/examples/Advanced/MidiScaler/MidiReader.hpp +++ b/examples/Advanced/MidiScaler/MidiReader.hpp @@ -24,7 +24,7 @@ struct MidiFileReader halp_meta(c_name, "avnd_helpers_midifile") halp_meta(manual_url, "https://ossia.io/score-docs/processes/midi-file-reader.html") halp_meta(uuid, "f2972e8f-17c3-40bb-919e-2663fe6fb3a5") - halp_meta(category, "Midi") + halp_meta(category, "Midi/Sequencers") halp_meta(description, "Play a selected MIDI-file track in musical time.") struct diff --git a/examples/Advanced/MidiScaler/MidiScaler.hpp b/examples/Advanced/MidiScaler/MidiScaler.hpp index 3e378a8b0..ca5edae97 100644 --- a/examples/Advanced/MidiScaler/MidiScaler.hpp +++ b/examples/Advanced/MidiScaler/MidiScaler.hpp @@ -6,10 +6,16 @@ #include "Tunings.h" #include "TuningsImpl.h" +#include +#include +#include + #include #include +#include #include #include +#include #include #include #include @@ -17,6 +23,7 @@ #include +#include #include #include #include @@ -38,10 +45,44 @@ struct MidiScaler halp_meta(c_name, "avnd_helpers_midifile_scaled") halp_meta(manual_url, "https://ossia.io/score-docs/processes/midi-file-scaler.html") halp_meta(uuid, "c43300b0-6c98-4a38-81f2-b549f0297e38") - halp_meta(category, "Midi") - halp_meta(description, "Convert active MIDI notes to frequencies using Scala tuning and keyboard-mapping files.") + halp_flag(process_exec); + halp_meta(category, "Midi/Pitch") + halp_meta(description, "Convert active MIDI notes to frequencies using Scala tuning and keyboard-mapping files, and play them retuned on a MIDI output.") - struct + struct output_mode_control + { + static clang_buggy_consteval auto name() { return std::string_view{"MIDI output"}; } + halp_meta( + description, + "Pitch bend: each note gets a channel of its own and a pitch bend. " + "MTS sysex: notes pass unchanged and the tuning of the 128 keys is sent " + "as MIDI Tuning Standard messages, followed on every channel by the " + "tuning program select, whenever it changes, when playback starts and " + "on Send tuning.") + enum enum_type + { + pitch_bend, + mts_sysex + } value{pitch_bend}; + enum widget + { + combobox + }; + struct range + { + std::string_view values[2]{"Pitch bend", "MTS sysex"}; + enum_type init = pitch_bend; + }; + operator enum_type&() noexcept { return value; } + operator const enum_type&() const noexcept { return value; } + auto& operator=(enum_type t) noexcept + { + value = t; + return *this; + } + }; + + struct ins { // This port will read a midi file halp::midi_bus<"MIDI messages"> midi; @@ -108,15 +149,109 @@ struct MidiScaler halp::hslider_f32<"Frequency adjust", halp::range{-500., 500., 0.}> adjust; halp::hslider_f32<"Frequency scale", halp::range{0.5, 2., 1.}> rescale; + + output_mode_control output_mode; + + struct : halp::spinbox_i32<"Bend range", halp::range{1, 96, 2}> + { + halp_meta( + description, + "Pitch bend range of the receiving synth in semitones: 2 for most " + "synths, 48 for MPE synths. Incoming pitch bend is read with the same range.") + } bend_range; + + struct : halp::channel_mask<"Channels"> + { + halp_meta( + description, "Channels the notes are spread over. None: all 16 channels.") + } channels; + + struct : halp::toggle<"Always send bend"> + { + halp_meta( + description, + "Send a pitch bend before every note, even when the channel already " + "has the right one") + } always_bend; + + struct : halp::spinbox_i32<"Input bend range", halp::range{1, 96, 2}> + { + halp_meta( + description, + "Range in semitones of the incoming pitch bend, e.g. 2 for a keyboard " + "wheel; the output uses Bend range") + } input_bend_range; + + struct : halp::impulse_button<"Send tuning"> + { + halp_meta( + description, + "Send the MTS tuning and the tuning program select again, e.g. to a " + "synth started or connected after playback") + void update(MidiScaler& self) { self.m_send_tuning = true; } + } send_tuning; } inputs; + struct ui + { + halp_meta(name, "Midi scaler") + halp_meta(layout, halp::layouts::hbox) + halp_meta(background, halp::colors::background_dark) + + struct + { + halp_meta(name, "Tuning") + halp_meta(layout, halp::layouts::section) + halp_meta(background, halp::colors::background_mid) + halp::item<&ins::scala> scala; + halp::item<&ins::kbm> kbm; + halp::item<&ins::adjust> adjust; + halp::item<&ins::rescale> rescale; + } tuning; + + struct + { + halp_meta(name, "MIDI output") + halp_meta(layout, halp::layouts::section) + halp_meta(background, halp::colors::background_mid) + halp::item<&ins::output_mode> output_mode; + + struct + { + halp_meta(layout, halp::layouts::vbox) + static constexpr auto condition() + { + return halp::enabled_when<&ins::output_mode, output_mode_control::pitch_bend>{}; + } + halp::item<&ins::bend_range> bend_range; + halp::item<&ins::channels> channels; + halp::item<&ins::always_bend> always_bend; + halp::item<&ins::input_bend_range> input_bend_range; + } bend; + + struct + { + halp_meta(layout, halp::layouts::vbox) + static constexpr auto condition() + { + return halp::enabled_when<&ins::output_mode, output_mode_control::mts_sysex>{}; + } + halp::item<&ins::send_tuning> send_tuning; + } mts; + } output; + }; + struct { struct { halp_meta(name, "Frequency output"); + //! At most one value per key: reserved in prepare so that the audio + //! thread never grows it. std::vector value{}; } freq; + + halp::midi_bus<"MIDI", libremidi::message> midi; } outputs; void update_tuning() @@ -132,9 +267,11 @@ struct MidiScaler this->tuning = Tunings::Tuning{}; this->tuning = this->tuning.withSkippedNotesInterpolated(); + m_tuning_changed = true; } catch(...) { + m_tuning_changed = true; // Maybe the tuning & mapping did not fit together try { @@ -158,25 +295,38 @@ struct MidiScaler std::array active = {}; - void prepare(halp::setup s) { update_tuning(); } + void prepare(halp::setup s) + { + outputs.freq.value.reserve(128); + // A tuning send and a tick of notes fit without growing on the audio thread. + outputs.midi.midi_messages.reserve(4 * mts_tuning_messages); + update_tuning(); + } + + //! The receiver may have been reset or started since the tuning was last + //! sent: it goes out again when playback starts. + void start() noexcept { m_send_tuning = true; } + void stop() noexcept { m_stop = true; } void operator()(int N) { + process_midi_output(N); + outputs.freq.value.clear(); // Update the active notes for(auto& msg : this->inputs.midi) { - libremidi::message m; - m.bytes.assign(msg.bytes.begin(), msg.bytes.end()); - - switch(m.get_message_type()) + const auto& b = msg.bytes; + if(b.size() < 3) + continue; + switch(b[0] & 0xF0) { - case libremidi::message_type::NOTE_ON: - this->active[m.bytes[1]] = m.bytes[2]; + case 0x90: + this->active[b[1] & 0x7F] = b[2]; break; - case libremidi::message_type::NOTE_OFF: - this->active[m.bytes[1]] = 0; + case 0x80: + this->active[b[1] & 0x7F] = 0; break; default: break; @@ -192,5 +342,381 @@ struct MidiScaler } } } + //! Fractional MIDI note (69 = A 440) of a key with the current tuning and + //! frequency controls, or nothing when the frequency is not positive. + std::optional target_pitch(int key) const noexcept + { + const double f = (tuning.frequencyForMidiNote(key) + double(inputs.adjust)) + * double(inputs.rescale); + if(!(f > 0.)) + return std::nullopt; + return 12. * std::log2(f / Tunings::MIDI_0_FREQ); + } + + static int bend_value(double semitones, int range) noexcept + { + const double v = 8192. + std::round(semitones / double(range) * 8192.); + return int(std::clamp(v, 0., 16383.)); + } + + //! MIDI Tuning Standard, real-time single note tuning change: + //! F0 7F 08 02 [ ]... F7. + //! Bytes a message holds without allocating: 32 on 64-bit targets, 16 on + //! 32-bit ones (wasm). + static constexpr std::size_t mts_inline_bytes = [] { + if constexpr(requires { libremidi::midi_bytes::static_capacity; }) + return std::size_t(libremidi::midi_bytes::static_capacity); + else + return std::size_t(24); + }(); + //! A message is 8 bytes plus 4 per key; as many keys as fit inline, a + //! divisor of 128. + static constexpr int mts_keys_per_message + = 8 + 4 * 4 <= mts_inline_bytes ? 4 : 8 + 4 * 2 <= mts_inline_bytes ? 2 : 1; + static_assert( + 8 + 4 * mts_keys_per_message <= mts_inline_bytes, + "an MTS message would allocate on the audio thread"); + + //! Per channel: tuning bank select (RPN 0 4), tuning program select (RPN 0 3), + //! both 0 as in the MTS messages, then the null RPN so that later data entry + //! changes nothing. + static constexpr int mts_select_messages_per_channel = 8; + static constexpr int mts_tuning_messages + = 128 / mts_keys_per_message + 16 * mts_select_messages_per_channel; + + //! A synth applies a tuning program only to the channels that selected it: + //! FluidSynth leaves the keys of a channel untuned until RPN 3 picks the + //! program, and takes the bank from RPN 4. + template + static void send_tuning_select(int64_t ts, Emit& emit) noexcept + { + for(int ch = 1; ch <= 16; ch++) + { + const uint8_t c = uint8_t(ch); + using libremidi::channel_events; + for(auto m : + {channel_events::control_change(c, 101, 0), + channel_events::control_change(c, 100, 4), + channel_events::control_change(c, 6, 0), + channel_events::control_change(c, 101, 0), + channel_events::control_change(c, 100, 3), + channel_events::control_change(c, 6, 0), + channel_events::control_change(c, 101, 127), + channel_events::control_change(c, 100, 127)}) + { + m.timestamp = ts; + emit(std::move(m)); + } + } + } + + //! The tuning of the 128 keys, then the selection of its program. + template + void send_tuning(int64_t ts, Emit& emit) const noexcept + { + send_mts(ts, emit); + send_tuning_select(ts, emit); + } + + template + void send_mts(int64_t ts, Emit& emit) const noexcept + { + for(int first = 0; first < 128; first += mts_keys_per_message) + { + libremidi::message m; + m.bytes = {0xF0, 0x7F, 0x7F, 0x08, 0x02, 0x00, uint8_t(mts_keys_per_message)}; + for(int key = first; key < first + mts_keys_per_message; key++) + { + const auto data = mts_key_data(key); + m.bytes.push_back(uint8_t(key)); + m.bytes.push_back(data[0]); + m.bytes.push_back(data[1]); + m.bytes.push_back(data[2]); + } + m.bytes.push_back(0xF7); + m.timestamp = ts; + emit(std::move(m)); + } + } + + //! Semitone, then the 14-bit fraction of a semitone, MSB first. + //! 7F 7F 7F means "no change" and is sent for unplayable frequencies. + std::array mts_key_data(int key) const noexcept + { + const auto t = target_pitch(key); + if(!t) + return {0x7F, 0x7F, 0x7F}; + const double p = std::clamp(*t, 0., 127. + 16382. / 16384.); + int semi = int(std::floor(p)); + int frac = int(std::round((p - semi) * 16384.)); + if(frac >= 16384) + { + semi++; + frac = 0; + } + if(semi > 127) + { + semi = 127; + frac = 16382; + } + return {uint8_t(semi), uint8_t((frac >> 7) & 0x7F), uint8_t(frac & 0x7F)}; + } + + //! Bit n set: channel n + 1 is in the "Channels" control. + uint16_t channel_mask() noexcept + { + const uint16_t mask = inputs.channels.mask(); + return mask == 0 ? uint16_t(0xFFFF) : mask; + } + + void process_midi_output(int N) noexcept + { + const int frames = N > 0 ? N : 0; + auto& out = outputs.midi; + + auto emit = [this, &out](libremidi::message&& m) noexcept { + if(is_note_off(m)) + { + const int ch = channel_of(m); + if(m_taken[ch - 1] > 0) + { + m_taken[ch - 1]--; + m_alloc.release(ch, m_now + m.timestamp); + } + } + out.push_back(std::move(m)); + }; + + if(m_stop) + { + m_notes.end_all(0, emit); + m_alloc.reset(); + m_taken = {}; + m_global_bend = 0.; + m_stop = false; + } + + const auto mode = inputs.output_mode.value; + const int range = std::clamp(int(inputs.bend_range.value), 1, 96); + const uint16_t mask = channel_mask(); + const float adjust = inputs.adjust; + const float rescale = inputs.rescale; + const bool retuned = m_tuning_changed || adjust != m_adjust || rescale != m_rescale; + + if(mode != m_mode || range != m_range || mask != m_mask) + { + // Held notes were placed for the previous settings: they end here so + // that each of them still gets its note-off on its own channel. + m_notes.end_all(0, emit); + m_mode = mode; + m_range = range; + m_mask = mask; + m_alloc.set_channels(mask); + m_mts_sent = false; + } + m_tuning_changed = false; + m_adjust = adjust; + m_rescale = rescale; + + if(mode == output_mode_control::mts_sysex && (retuned || !m_mts_sent || m_send_tuning)) + { + send_tuning(0, emit); + m_mts_sent = true; + } + m_send_tuning = false; + + for(const auto& in : this->inputs.midi) + { + libremidi::message m; + m.bytes.assign(in.bytes.begin(), in.bytes.end()); + m.timestamp = frames > 0 ? std::clamp(in.timestamp, 0, frames - 1) : 0; + + if(mode == output_mode_control::mts_sysex) + handle_mts(m, emit); + else + handle_bend(m, emit); + } + + m_now += frames; + } + + template + void handle_mts(libremidi::message& m, Emit& emit) noexcept + { + const auto ts = m.timestamp; + if(is_note_on(m)) + { + auto& h = m_notes.begin(channel_of(m), m.bytes[1], ts, emit); + m_notes.play(h, channel_of(m), m.bytes[1], m.bytes[2], ts, emit); + } + else if(is_note_off(m)) + { + if(!m_notes.end(channel_of(m), m.bytes[1], ts, m.bytes[2], emit)) + emit(std::move(m)); + } + else + { + if(is_all_notes_off(m)) + m_notes.end_all(ts, emit); + emit(std::move(m)); + } + } + + template + void handle_bend(libremidi::message& m, Emit& emit) noexcept + { + const auto ts = m.timestamp; + switch(classify(m)) + { + case midi_kind::note_on: + start_bent_note(channel_of(m), m.bytes[1], m.bytes[2], ts, emit); + break; + + case midi_kind::note_off: + // A note-off without a note is dropped: its key and channel mean + // nothing on the output. + m_notes.end(channel_of(m), m.bytes[1], ts, m.bytes[2], emit); + break; + + case midi_kind::pitch_bend: { + if(m.size() < 3) + break; + const int v = m.bytes[1] | (m.bytes[2] << 7); + m_global_bend = double(v - 8192) / 8192. * inputs.input_bend_range.value; + for(int ch = 1; ch <= 16; ch++) + if(m_taken[ch - 1] > 0) + send_bend(ch, ts, true, emit); + break; + } + + case midi_kind::poly_pressure: { + // Follows the note it applies to. + for(int i = 0; i < m_notes.size(); i++) + { + const auto& h = m_notes[i]; + if(h.channel == channel_of(m) && h.pitch == m.bytes[1] && h.count > 0) + { + auto msg = libremidi::channel_events::poly_pressure( + h.outputs[0].channel, h.outputs[0].pitch, m.bytes[2]); + msg.timestamp = ts; + emit(std::move(msg)); + break; + } + } + break; + } + + case midi_kind::control_change: + case midi_kind::program_change: + case midi_kind::channel_pressure: + // Channel-wide messages reach every channel the notes may be on. + if(is_all_notes_off(m)) + m_notes.end_all(ts, emit); + for(int ch = 1; ch <= 16; ch++) + { + if(m_alloc.usable(ch)) + { + auto msg = m; + set_channel(msg, ch); + emit(std::move(msg)); + } + } + break; + + default: + emit(std::move(m)); + break; + } + } + + template + void send_bend(int ch, int64_t ts, bool always, Emit& emit) noexcept + { + const int v = bend_value(m_offset[ch - 1] + m_global_bend, m_range); + if(!always && m_last_bend[ch - 1] == v) + return; + m_last_bend[ch - 1] = v; + auto msg = libremidi::channel_events::pitch_bend(ch, v); + msg.timestamp = ts; + emit(std::move(msg)); + } + + template + void start_bent_note(int in_ch, int key, int vel, int64_t ts, Emit& emit) noexcept + { + const auto t = target_pitch(key); + if(!t) + return; + + // The nearest note to the bent pitch keeps the bend within range. + const double bent = *t + m_global_bend; + const int note = std::clamp(int(std::lround(bent)), 0, 127); + const double offset = *t - note; + if(std::abs(offset + m_global_bend) > m_range + 1e-9) + return; + + const int64_t now = m_now + ts; + std::optional ch; + for(int attempt = 0; attempt < 16 && !ch; attempt++) + { + ch = m_alloc.allocate(now); + if(ch) + break; + + // Every channel is busy: a channel whose notes already have this bend + // can take the note too. + for(int c = 1; c <= 16 && !ch; c++) + { + if(m_alloc.usable(c) && m_taken[c - 1] > 0 + && std::abs(m_offset[c - 1] - offset) < 1e-6 && m_notes.sounding(c, note) == 0) + { + m_alloc.take(c); + ch = c; + } + } + if(ch) + break; + + // Otherwise the oldest note makes room. + if(m_notes.size() == 0) + break; + const int old_ch = m_notes[0].channel; + const int old_pitch = m_notes[0].pitch; + m_notes.end(old_ch, old_pitch, ts, 0, emit); + } + if(!ch) + return; + + m_taken[*ch - 1]++; + m_offset[*ch - 1] = offset; + send_bend(*ch, ts, inputs.always_bend.value, emit); + + auto& h = m_notes.begin(in_ch, key, ts, emit); + m_notes.play(h, *ch, note, vel, ts, emit); + } + + note_tracker<128, 1> m_notes; + channel_allocator m_alloc; + + //! Per output channel: tuning offset of its notes in semitones, last pitch + //! bend sent (-1: unknown), and notes started on it. + std::array m_offset{}; + std::array m_last_bend{-1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1}; + std::array m_taken{}; + + //! Incoming pitch bend, in semitones, added to every note. + double m_global_bend{}; + + int64_t m_now{}; + output_mode_control::enum_type m_mode{output_mode_control::pitch_bend}; + int m_range{2}; + uint16_t m_mask{0xFFFF}; + float m_adjust{0.f}; + float m_rescale{1.f}; + bool m_tuning_changed{true}; + bool m_mts_sent{false}; + bool m_send_tuning{false}; + bool m_stop{false}; }; } diff --git a/examples/Advanced/MidiScaler/MidiScroller.hpp b/examples/Advanced/MidiScaler/MidiScroller.hpp index e1f00cbea..7a131b5aa 100644 --- a/examples/Advanced/MidiScaler/MidiScroller.hpp +++ b/examples/Advanced/MidiScaler/MidiScroller.hpp @@ -26,7 +26,7 @@ struct MidiScroller halp_meta(c_name, "avnd_helpers_scroller") halp_meta(manual_url, "https://ossia.io/score-docs/processes/midi-file-scroller.html") halp_meta(uuid, "6a1429f8-f466-4748-ad1d-4fdd644e2359") - halp_meta(category, "Midi") + halp_meta(category, "Midi/Sequencers") halp_meta(description, "Scrub through a MIDI-file track with a manually controlled playback position.") struct diff --git a/examples/Advanced/MidiScaler/MidiSustain.hpp b/examples/Advanced/MidiScaler/MidiSustain.hpp new file mode 100644 index 000000000..75c5b95ea --- /dev/null +++ b/examples/Advanced/MidiScaler/MidiSustain.hpp @@ -0,0 +1,284 @@ +#pragma once + +/* SPDX-License-Identifier: GPL-3.0-or-later */ + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace mtk +{ +/** + * @brief A sustain pedal applied to the MIDI stream: note-offs are held back + * while the pedal is down, and sent when it goes up. + * + * The pedal is CC 64 of each channel (64 and above is down), or the "Pedal" + * control, which holds every channel. Modes: + * - All: every note released while the pedal is down rings until it goes up. + * - Chord: as All, but starting a new chord (a note-on while no key is held + * on the channel) ends the notes the pedal holds, so only the last chord + * rings. + * - Sostenuto: only the notes whose key is down when the pedal goes down ring. + * - Semi-sostenuto: the notes whose key is down when the pedal goes down ring + * until it goes up, and the notes played afterwards ring as in Chord. + * + * A note the pedal holds that is struck again is ended before its new note-on, + * so that every note-on sent gets one note-off. + */ +struct MidiSustain +{ + halp_meta(name, "Midi sustain") + halp_meta(c_name, "midi_sustain") + halp_meta(category, "Midi/Timing") + halp_meta(author, "ossia score") + halp_meta(description, "Hold notes after their key is released, like a sustain pedal") + halp_meta(manual_url, "https://ossia.io/score-docs/processes/midi-sustain.html") + halp_meta(uuid, "943f891c-e56f-4e60-a270-3420657fe368") + halp_flag(process_exec); + + enum Mode : uint8_t + { + All, + Chord, + SemiSostenuto, + Sostenuto + }; + + struct ins + { + halp::midi_bus<"MIDI", libremidi::message> midi; + + struct : halp::enum_t + { + halp_meta( + description, + "All holds every note; Chord only the last chord; Sostenuto only the " + "notes held when the pedal goes down; Semi-sostenuto those, and the " + "last chord played after") + struct range + { + std::string_view values[4] = {"All", "Chord", "Semi-sostenuto", "Sostenuto"}; + Mode init = All; + }; + } mode; + + struct : halp::maintained_button<"Pedal"> + { + halp_meta( + description, + "Holds the pedal down on every channel while pressed, as CC 64 does on one") + } pedal; + + struct : halp::toggle<"Pass CC 64", halp::toggle_setup{.init = true}> + { + halp_meta(description, "Send the incoming CC 64 on, or keep it") + } pass_pedal; + } inputs; + + struct + { + halp::midi_bus<"MIDI", libremidi::message> midi; + } outputs; + + void stop() noexcept { m_stop_requested = true; } + + using tick = halp::tick; + void operator()(halp::tick t) noexcept + { + const int frames = t.frames > 0 ? t.frames : 0; + if(m_stop_requested) + { + m_stop_requested = false; + release_all(0); + m_cc_down = {}; + m_pedal_down = {}; + } + + for(int c = 0; c < 16; c++) + update_pedal(c, 0); + + for(const auto& m : inputs.midi) + { + const int64_t ts = frames > 0 ? std::clamp(m.timestamp, 0, frames - 1) : 0; + handle(m, ts); + } + } + + //! Whether a note sounds at the output, held by its key or by the pedal. + bool sounding(int channel, int pitch) const noexcept + { + const auto& n = m_notes[(channel - 1) & 15][pitch & 127]; + return n.key || n.sustained; + } + +private: + struct note_state + { + bool key{}; //!< its key is down + bool sustained{}; //!< its key is up, the pedal holds it + bool latched{}; //!< its key was down when the pedal went down + uint8_t release_velocity{}; + }; + + void send(libremidi::message m, int64_t ts) noexcept + { + m.timestamp = ts; + outputs.midi.push_back(std::move(m)); + } + + void end_note(int c, int p, int64_t ts) noexcept + { + auto& n = m_notes[c][p]; + if(n.key) + m_keys_down[c]--; + send(libremidi::channel_events::note_off(c + 1, p, n.release_velocity), ts); + n = {}; + } + + void handle(const libremidi::message& m, int64_t ts) noexcept + { + const int c = channel_of(m) - 1; + switch(classify(m)) + { + case midi_kind::note_on: + note_on(m, c, m.bytes[1] & 127, ts); + return; + case midi_kind::note_off: + if(m.size() >= 3) + { + const int vel = (m.bytes[0] & 0xF0) == 0x80 ? (m.bytes[2] & 127) : 0; + note_off(c, m.bytes[1] & 127, vel, ts); + return; + } + break; + case midi_kind::control_change: + if(m.size() < 3) + break; + if(m.bytes[1] == 64) + { + m_cc_down[c] = m.bytes[2] >= 64; + if(inputs.pass_pedal.value) + send(m, ts); + update_pedal(c, ts); + return; + } + if(m.bytes[1] == 121) + { + // Reset all controllers lifts the pedal. + m_cc_down[c] = false; + send(m, ts); + update_pedal(c, ts); + return; + } + if(is_all_notes_off(m)) + { + for(int p = 0; p < 128; p++) + if(m_notes[c][p].key || m_notes[c][p].sustained) + end_note(c, p, ts); + } + break; + default: + break; + } + send(m, ts); + } + + bool held_by_pedal(const note_state& n, int mode) const noexcept + { + switch(mode) + { + case Sostenuto: + return n.latched; + case All: + case Chord: + case SemiSostenuto: + default: + return true; + } + } + + void note_on(const libremidi::message& m, int c, int p, int64_t ts) noexcept + { + auto& n = m_notes[c][p]; + if(n.key || n.sustained) + end_note(c, p, ts); + + const int mode = inputs.mode.value; + if(m_pedal_down[c] && m_keys_down[c] == 0 && (mode == Chord || mode == SemiSostenuto)) + { + // A new chord: the previous one stops ringing. Semi-sostenuto keeps + // the notes held when the pedal went down. + for(int k = 0; k < 128; k++) + if(m_notes[c][k].sustained && (mode == Chord || !m_notes[c][k].latched)) + end_note(c, k, ts); + } + + n.key = true; + m_keys_down[c]++; + send(m, ts); + } + + void note_off(int c, int p, int vel, int64_t ts) noexcept + { + auto& n = m_notes[c][p]; + if(!n.key) + return; + n.release_velocity = uint8_t(vel); + if(m_pedal_down[c] && held_by_pedal(n, inputs.mode.value)) + { + n.key = false; + n.sustained = true; + m_keys_down[c]--; + return; + } + end_note(c, p, ts); + } + + //! Applies a change of the pedal of channel c (CC or control). + void update_pedal(int c, int64_t ts) noexcept + { + const bool down = m_cc_down[c] || inputs.pedal.value; + if(down == m_pedal_down[c]) + return; + m_pedal_down[c] = down; + if(down) + { + for(auto& n : m_notes[c]) + n.latched = n.key; + } + else + { + for(int p = 0; p < 128; p++) + { + auto& n = m_notes[c][p]; + n.latched = false; + if(n.sustained) + end_note(c, p, ts); + } + } + } + + void release_all(int64_t ts) noexcept + { + for(int c = 0; c < 16; c++) + for(int p = 0; p < 128; p++) + if(m_notes[c][p].key || m_notes[c][p].sustained) + end_note(c, p, ts); + m_keys_down = {}; + } + + std::array, 16> m_notes{}; + std::array m_keys_down{}; + std::array m_cc_down{}; + std::array m_pedal_down{}; + bool m_stop_requested{}; +}; +} diff --git a/examples/Advanced/MidiScaler/MidiTransform.hpp b/examples/Advanced/MidiScaler/MidiTransform.hpp new file mode 100644 index 000000000..db6f1c23e --- /dev/null +++ b/examples/Advanced/MidiScaler/MidiTransform.hpp @@ -0,0 +1,759 @@ +#pragma once + +/* SPDX-License-Identifier: GPL-3.0-or-later */ + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace mtk +{ +/** + * @brief Turns one kind of MIDI message into another, rule based. + * + * A message of kind "What" that passes the input filter (channel, data byte + * ranges) produces a message of kind "Into", whose channel and data bytes come + * from the selected sources. Sources in 1-16 (channels) and sources in 0-127 + * (data bytes, arguments) are scaled linearly when they feed the other range. + * Missing data bytes read as 0. Pitch bend and song position use byte 1 as + * the LSB and byte 2 as the MSB. + * + * Note pairing, so that no note hangs: + * - "What" Note on also owns the note-off of each note-on it matched: when + * the note-on was replaced or dropped, its note-off is removed too. + * - A note-off whose note-on went out unchanged is never removed: in Replace + * and Drop modes it is kept (Replace still adds the produced message). + * - "Into" Note on: each produced note belongs to its source, identified by + * kind, channel, and note or controller number. It ends when that source + * ends: the note-off of a note-on, a controller, poly or channel pressure + * value of 0, or a produced velocity of 0. A source that has no end + * (program change, clock, start) ends its previous note when it fires again. + * - CC 120 and 123 end every produced note and pass through untransformed. + * + * The Value output gives the value of each message the transformation + * produces, at its time, as message_value() reads it: 0 - 127 for the data + * bytes, -1 to 1 for pitch bend, 0 - 16383 for song position. Messages that + * pass through untransformed, and the note-offs sent on stop, have none. + */ +struct MidiTransform +{ + halp_meta(name, "Midi transform") + halp_meta(c_name, "midi_transform") + halp_meta(category, "Midi/Routing") + halp_meta(author, "ossia score") + halp_meta( + description, + "Turn one kind of MIDI message into another, with the channel and data " + "bytes taken from the input or from arguments") + halp_meta(manual_url, "https://ossia.io/score-docs/processes/midi-transform.html") + halp_meta(uuid, "1680d787-cb5d-4df3-8783-8bca4c3cad27") + halp_flag(process_exec); + + enum What + { + NoteOn, + NoteOff, + CC, + ProgramChange, + ChannelPressure, + PolyPressure, + PitchBend, + TimeCode, + SongPosition, + SongSelect, + TuneRequest, + Clock, + Start, + Continue, + Stop, + ActiveSensing, + Reset, + Sysex + }; + static constexpr int into_count = 17; //!< every What but Sysex + + enum Mode + { + InsertBefore, + InsertAfter, + Replace, + Drop + }; + + enum Source + { + Arg1, + Arg2, + ChannelArg, + DataByte1, + DataByte2, + IncomingChannel + }; + + enum Test + { + Inside, + Outside + }; + +#define MTK_KIND_NAMES \ + "Note on", "Note off", "CC", "Program change", "Channel pressure", \ + "Poly pressure", "Pitch bend", "MTC quarter frame", "Song position", \ + "Song select", "Tune request", "Clock", "Start", "Continue", "Stop", \ + "Active sensing", "Reset" +#define MTK_SOURCE_NAMES \ + "Value 1", "Value 2", "Fixed channel", "Data byte 1", "Data byte 2", "Incoming channel" + + struct ins + { + halp::midi_bus<"MIDI", libremidi::message> midi; + + struct : halp::enum_t + { + enum widget + { + combobox + }; + struct range + { + std::string_view values[18] = {MTK_KIND_NAMES, "Sysex"}; + What init{}; + }; + halp_meta(description, "Kind of message that is transformed") + } what; + + struct : halp::enum_t + { + enum widget + { + combobox + }; + struct range + { + std::string_view values[into_count] = {MTK_KIND_NAMES}; + What init{}; + }; + halp_meta(description, "Kind of message produced") + } into; + + struct : halp::enum_t + { + enum widget + { + combobox + }; + struct range + { + std::string_view values[4] = {"Insert before", "Insert after", "Replace", "Drop"}; + Mode init{Replace}; + }; + halp_meta( + description, + "Insert: keep the original and add the produced message before or after " + "it. Replace: send only the produced message. Drop: remove matching " + "messages.") + } mode; + + struct : halp::enum_t + { + enum widget + { + combobox + }; + struct range + { + std::string_view values[6] = {MTK_SOURCE_NAMES}; + Source init{IncomingChannel}; + }; + halp_meta(description, "Source of the produced channel") + } channel_source; + + struct : halp::enum_t + { + enum widget + { + combobox + }; + struct range + { + std::string_view values[6] = {MTK_SOURCE_NAMES}; + Source init{DataByte1}; + }; + halp_meta(description, "Source of the produced first data byte") + } byte1_source; + + struct : halp::enum_t + { + enum widget + { + combobox + }; + struct range + { + std::string_view values[6] = {MTK_SOURCE_NAMES}; + Source init{DataByte2}; + }; + halp_meta(description, "Source of the produced second data byte") + } byte2_source; + + struct : halp::spinbox_i32<"Value 1", halp::range{0, 127, 0}> + { + halp_meta(description, "Constant usable as a source") + } arg1; + struct : halp::spinbox_i32<"Value 2", halp::range{0, 127, 0}> + { + halp_meta(description, "Constant usable as a source") + } arg2; + struct : halp::spinbox_i32<"Fixed channel", halp::range{1, 16, 1}> + { + halp_meta(description, "Channel usable as a source") + } channel_arg; + + struct : halp::spinbox_i32<"Only channel", halp::range{0, 16, 0}> + { + halp_meta(description, "Channel of the messages transformed (0 = all)") + } channel; + struct : halp::range_spinbox_t< + int, "Byte 1 range", + halp::range_slider_range{.min = 0, .max = 127, .init = {0, 127}}> + { + halp_meta( + description, "First data byte range tested, e.g. 0 to 63. The whole range: all.") + } byte1_range; + struct : halp::enum_t + { + halp_meta(description, "Transform messages inside or outside the Byte 1 range") + } byte1_test; + struct : halp::range_spinbox_t< + int, "Byte 2 range", + halp::range_slider_range{.min = 0, .max = 127, .init = {0, 127}}> + { + halp_meta( + description, "Second data byte range tested, e.g. 1 to 127. The whole range: all.") + } byte2_range; + struct : halp::enum_t + { + halp_meta(description, "Transform messages inside or outside the Byte 2 range") + } byte2_test; + + struct : halp::toggle<"Real-time first"> + { + halp_meta( + description, + "When the original and the produced message are both sent, a real-time " + "one (clock, start, stop) goes first") + } realtime_first; + } inputs; + +#undef MTK_KIND_NAMES +#undef MTK_SOURCE_NAMES + + struct + { + halp::midi_bus<"MIDI", libremidi::message> midi; + struct : halp::timed_callback<"Value", float> + { + halp_meta( + description, + "The value of each produced message: velocity, controller value, " + "pressure, program or other data byte in 0 - 127, pitch bend in -1 to " + "1, song position in 0 - 16383. Real-time messages have none.") + } value; + } outputs; + + struct ui + { + halp_meta(name, "Midi transform") + halp_meta(layout, halp::layouts::tabs) + halp_meta(background, halp::colors::background_mid) + + struct + { + halp_meta(name, "Convert") + halp_meta(layout, halp::layouts::hbox) + struct + { + halp_meta(layout, halp::layouts::vbox) + halp::item<&ins::what> what; + halp::item<&ins::into> into; + } kinds; + struct + { + halp_meta(layout, halp::layouts::vbox) + halp::item<&ins::mode> mode; + halp::item<&ins::realtime_first> realtime_first; + } how; + } convert; + + struct + { + halp_meta(name, "Output") + halp_meta(layout, halp::layouts::hbox) + struct + { + halp_meta(layout, halp::layouts::vbox) + halp::item<&ins::channel_source> channel_source; + halp::item<&ins::byte1_source> byte1_source; + halp::item<&ins::byte2_source> byte2_source; + } sources; + struct + { + halp_meta(layout, halp::layouts::vbox) + halp::item<&ins::channel_arg> channel_arg; + halp::item<&ins::arg1> arg1; + halp::item<&ins::arg2> arg2; + } constants; + } output; + + struct + { + halp_meta(name, "Input filter") + halp_meta(layout, halp::layouts::hbox) + halp::item<&ins::channel> channel; + struct + { + halp_meta(layout, halp::layouts::vbox) + halp::item<&ins::byte1_range> byte1_range; + halp::item<&ins::byte1_test> byte1_test; + } byte1; + struct + { + halp_meta(layout, halp::layouts::vbox) + halp::item<&ins::byte2_range> byte2_range; + halp::item<&ins::byte2_test> byte2_test; + } byte2; + } filter; + }; + + MidiTransform() noexcept + { + inputs.mode.value = Replace; + inputs.channel_source.value = IncomingChannel; + inputs.byte1_source.value = DataByte1; + inputs.byte2_source.value = DataByte2; + } + + void stop() noexcept { m_stop = true; } + + //! The whole 0-127 range tests nothing + static bool full(halp::range_slider_value r) noexcept + { + return std::min(r.start, r.end) <= 0 && std::max(r.start, r.end) >= 127; + } + static bool in_range(halp::range_slider_value r, int v) noexcept + { + return v >= std::min(r.start, r.end) && v <= std::max(r.start, r.end); + } + + using tick = halp::tick_musical; + void operator()(halp::tick_musical) noexcept + { + + if(m_stop) + { + m_stop = false; + end_everything(0); + } + + // The messages that would end a produced note may no longer reach it + const auto what = What(inputs.what.value); + const auto into = What(inputs.into.value); + if(what != m_what || into != m_into) + { + m_tracker.end_all(0, [this](libremidi::message&& m) { emit(m); }); + m_what = what; + m_into = into; + } + + for(const auto& m : inputs.midi) + process(m); + } + + static midi_kind to_midi_kind(What w) noexcept + { + using enum midi_kind; + static constexpr midi_kind table[] = { + note_on, note_off, control_change, program_change, channel_pressure, + poly_pressure, pitch_bend, time_code, song_position, song_select, + tune_request, clock, start, continue_, stop, + active_sensing, reset, sysex}; + return table[int(w)]; + } + +private: + note_tracker<128, 1> m_tracker; + //! Note-ons that went out unchanged, by input channel and note. + std::array, 16> m_passed{}; + //! Note-ons that were replaced or dropped, by input channel and note. + std::array, 16> m_removed{}; + What m_what{NoteOn}; + What m_into{NoteOn}; + bool m_stop{}; + + void emit(const libremidi::message& m) { outputs.midi.push_back(m); } + + //! A message the transformation produced: its value goes out as well. + void emit_produced(const libremidi::message& m) + { + emit(m); + if(auto v = message_value(m)) + outputs.value(m.timestamp, *v); + } + + static bool is_realtime(const libremidi::message& m) noexcept + { + return m.size() > 0 && m.bytes[0] >= 0xF8; + } + + static int byte(const libremidi::message& m, int i) noexcept + { + return int(m.size()) > i ? m.bytes[i] : 0; + } + + void end_everything(int64_t ts) + { + m_tracker.end_all(ts, [this](libremidi::message&& m) { emit(m); }); + for(int c = 0; c < 16; c++) + { + for(int n = 0; n < 128; n++) + { + for(int i = 0; i < m_passed[c][n]; i++) + { + auto off = libremidi::channel_events::note_off(c + 1, n, 0); + off.timestamp = ts; + emit(off); + } + } + } + m_passed = {}; + m_removed = {}; + } + + bool matches_filter(const libremidi::message& m) const noexcept + { + if(is_channel_message(m) && inputs.channel != 0 && channel_of(m) != inputs.channel) + return false; + if(!full(inputs.byte1_range.value) + && in_range(inputs.byte1_range.value, byte(m, 1)) != (inputs.byte1_test == Inside)) + return false; + if(!full(inputs.byte2_range.value) + && in_range(inputs.byte2_range.value, byte(m, 2)) != (inputs.byte2_test == Inside)) + return false; + return true; + } + + //! Value of a source, in 1-16 when `as_channel`, in 0-127 otherwise. + int source(Source s, const libremidi::message& m, bool as_channel) const noexcept + { + int v{}; + bool is_channel{}; + switch(s) + { + case Arg1: + v = inputs.arg1; + break; + case Arg2: + v = inputs.arg2; + break; + case DataByte1: + v = byte(m, 1); + break; + case DataByte2: + v = byte(m, 2); + break; + case ChannelArg: + v = inputs.channel_arg; + is_channel = true; + break; + case IncomingChannel: + v = is_channel_message(m) ? channel_of(m) : int(inputs.channel_arg); + is_channel = true; + break; + } + if(is_channel) + { + v = std::clamp(v, 1, 16); + return as_channel ? v : ((v - 1) * 127 * 2 + 15) / 30; + } + v = std::clamp(v, 0, 127); + return as_channel ? 1 + (v * 15 * 2 + 127) / 254 : v; + } + + //! Identity of the source of a produced note, see note_tracker::begin. + static std::pair identity(const libremidi::message& m, midi_kind k) noexcept + { + using enum midi_kind; + if(!is_channel_message(m)) + return {128 + int(k), 0}; + const int c = channel_of(m) - 1; + switch(k) + { + case note_on: + case note_off: + return {c, byte(m, 1)}; + case control_change: + return {16 + c, byte(m, 1)}; + case poly_pressure: + return {32 + c, byte(m, 1)}; + default: + // program change, pitch bend, channel pressure: 48 to 95 + return {48 + (int(k) - int(program_change)) * 16 + c, 0}; + } + } + + //! Whether a matching message ends what its source produced. + bool is_end(const libremidi::message& m, midi_kind k) const noexcept + { + using enum midi_kind; + switch(k) + { + case control_change: + case poly_pressure: + return byte(m, 2) == 0; + case channel_pressure: + return byte(m, 1) == 0; + default: + return false; + } + } + + void end_produced(const libremidi::message& in, midi_kind k, int velocity) + { + const auto [ic, ip] = identity(in, k); + while(m_tracker.end(ic, ip, in.timestamp, velocity, [this](libremidi::message&& m) { + emit_produced(m); + })) + ; + } + + //! Sends what a matching message turns into. + void produce(const libremidi::message& in, midi_kind k) + { + const int ch = source(inputs.channel_source, in, true); + const int b1 = source(inputs.byte1_source, in, false); + const int b2 = source(inputs.byte2_source, in, false); + const auto st = [ch](uint8_t s) { return uint8_t(s | (ch - 1)); }; + const auto u = [](int v) { return uint8_t(v); }; + + libremidi::message out; + switch(What(inputs.into.value)) + { + case NoteOn: { + end_produced(in, k, 0); + if(is_end(in, k) || b2 == 0) + return; + const auto [ic, ip] = identity(in, k); + auto emitter = [this](libremidi::message&& m) { emit_produced(m); }; + auto& h = m_tracker.begin(ic, ip, in.timestamp, emitter); + m_tracker.play(h, ch, b1, b2, in.timestamp, emitter); + return; + } + case NoteOff: + out = {st(0x80), u(b1), u(b2)}; + break; + case CC: + out = {st(0xB0), u(b1), u(b2)}; + break; + case ProgramChange: + out = {st(0xC0), u(b1)}; + break; + case ChannelPressure: + out = {st(0xD0), u(b1)}; + break; + case PolyPressure: + out = {st(0xA0), u(b1), u(b2)}; + break; + case PitchBend: + out = {st(0xE0), u(b1), u(b2)}; + break; + case TimeCode: + out = {0xF1, u(b1)}; + break; + case SongPosition: + out = {0xF2, u(b1), u(b2)}; + break; + case SongSelect: + out = {0xF3, u(b1)}; + break; + case TuneRequest: + out = {0xF6}; + break; + case Clock: + out = {0xF8}; + break; + case Start: + out = {0xFA}; + break; + case Continue: + out = {0xFB}; + break; + case Stop: + out = {0xFC}; + break; + case ActiveSensing: + out = {0xFE}; + break; + case Reset: + out = {0xFF}; + break; + case Sysex: + return; + } + out.timestamp = in.timestamp; + emit_produced(out); + } + + bool into_is_realtime() const noexcept + { + const auto i = What(inputs.into.value); + return i == Clock || i == Start || i == Continue || i == Stop || i == ActiveSensing + || i == Reset; + } + + //! Sends the original and the produced message in the configured order. + void insert(const libremidi::message& in, midi_kind k, bool before) + { + if(inputs.realtime_first) + { + if(into_is_realtime() && !is_realtime(in)) + before = true; + else if(is_realtime(in) && !into_is_realtime()) + before = false; + } + if(before) + { + produce(in, k); + emit(in); + } + else + { + emit(in); + produce(in, k); + } + } + + void process(const libremidi::message& in) + { + if(in.size() == 0) + { + emit(in); + return; + } + + const auto k = classify(in); + const auto what = What(inputs.what.value); + const auto mode = Mode(inputs.mode.value); + + if(is_all_notes_off(in)) + { + m_tracker.end_all(in.timestamp, [this](libremidi::message&& m) { emit(m); }); + const int c = channel_of(in) - 1; + m_passed[c] = {}; + m_removed[c] = {}; + emit(in); + return; + } + + if(k == midi_kind::note_off && in.size() >= 3) + { + const int c = channel_of(in) - 1; + const int n = in.bytes[1]; + + // Whether the note-on went out decides the fate of the note-off, + // whatever What was when it came. + const bool removed = m_removed[c][n] > 0; + const bool kept_pair = !removed && m_passed[c][n] > 0; + if(removed) + m_removed[c][n]--; + else if(kept_pair) + m_passed[c][n]--; + + if(what == NoteOn) + { + const bool into_note = What(inputs.into.value) == NoteOn; + if(removed) + { + if(into_note) + end_produced(in, k, in.bytes[2]); + return; + } + const bool before = mode == InsertBefore; + if(into_note && before) + end_produced(in, k, in.bytes[2]); + emit(in); + if(into_note && !before) + end_produced(in, k, in.bytes[2]); + return; + } + + if(what != NoteOff || !matches_filter(in)) + { + if(!removed) + emit(in); + return; + } + + switch(mode) + { + case InsertBefore: + case InsertAfter: + if(removed) + produce(in, k); + else + insert(in, k, mode == InsertBefore); + break; + case Replace: + if(kept_pair) + insert(in, k, false); + else + produce(in, k); + break; + case Drop: + if(kept_pair) + emit(in); + break; + } + return; + } + + const bool match = k == to_midi_kind(what) && matches_filter(in); + const bool is_on = k == midi_kind::note_on; + if(!match) + { + if(is_on && m_passed[channel_of(in) - 1][in.bytes[1]] < 255) + m_passed[channel_of(in) - 1][in.bytes[1]]++; + emit(in); + return; + } + + switch(mode) + { + case InsertBefore: + case InsertAfter: + if(is_on && m_passed[channel_of(in) - 1][in.bytes[1]] < 255) + m_passed[channel_of(in) - 1][in.bytes[1]]++; + insert(in, k, mode == InsertBefore); + break; + case Replace: + case Drop: + if(is_on && m_removed[channel_of(in) - 1][in.bytes[1]] < 255) + m_removed[channel_of(in) - 1][in.bytes[1]]++; + if(mode == Replace) + produce(in, k); + break; + } + } +}; +} diff --git a/examples/Advanced/MidiScaler/MidiValueShaper.hpp b/examples/Advanced/MidiScaler/MidiValueShaper.hpp new file mode 100644 index 000000000..a0e8fc560 --- /dev/null +++ b/examples/Advanced/MidiScaler/MidiValueShaper.hpp @@ -0,0 +1,524 @@ +#pragma once + +/* SPDX-License-Identifier: GPL-3.0-or-later */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace mtk +{ +/** + * @brief Reshapes one kind of MIDI value: velocities, release velocities, + * controllers, pitch bend, channel or poly pressure. + * + * The operations form a chain in a fixed order, each one neutral at its + * default value: + * + * fixed, rescale, curve, compress, gain (with the key-tracked gain), + * offset, random, clip. + * + * Values are handled as real numbers on a 0 - 127 scale, so every control + * means the same thing whatever the kind. Pitch bend keeps its 14 bits: 0 is + * 0, 64 is the center 8192 and 127 is 16383, and nothing is rounded before the + * output. Curve, compress and gain act on the distance to the rest value: 0 + * for every kind but pitch bend, whose rest value is its center, so that its + * two halves are shaped alike. + * + * The curve is a single exponent rather than breakpoints: one knob, neutral in + * the middle, monotonic for any setting, as in the velocity curves of Live and + * Bitwig. Rescale and clip together give the corners that breakpoints would. + * + * The Value output gives each shaped value as it is sent, at its time: 0 - 127 + * for velocities, controllers and pressure, -1 to 1 for pitch bend (see + * message_value()). + */ +struct MidiValueShaper +{ +public: + halp_meta(name, "Midi value shaper") + halp_meta(c_name, "midi_value_shaper") + halp_meta(category, "Midi/Control") + halp_meta(author, "ossia score") + halp_meta( + description, + "Gain, curve, compression, rescaling, key tracking and randomization of " + "velocities, controllers, pitch bend and pressure") + halp_meta(manual_url, "https://ossia.io/score-docs/processes/midi-value-shaper.html") + halp_meta(uuid, "6ed46ee7-8d04-4e46-8a68-e2c6633fd217") + halp_flag(process_exec); + + enum Kind + { + Velocity, + Release, + CC, + PitchBend, + Pressure, + PolyPressure + }; + + struct ins + { + halp::midi_bus<"MIDI", libremidi::message> midi; + + // ---- Selection ------------------------------------------------------ + struct : halp::enum_t + { + enum widget + { + combobox + }; + halp_meta( + description, + "The value that is shaped: note-on velocity, note-off velocity, a " + "controller, pitch bend, channel pressure or poly pressure") + } kind; + + struct : halp::spinbox_i32<"CC or note", halp::range{-1, 127, -1}> + { + halp_meta( + description, + "Controller number for CC, note for poly pressure. -1: any note, or any " + "controller but bank select, RPN, NRPN and channel mode messages") + } number; + + struct : halp::spinbox_i32<"Channel", halp::range{0, 16, 0}> + { + halp_meta(description, "MIDI channel (0: all)") + } channel; + + // ---- Shape ---------------------------------------------------------- + struct : halp::toggle<"Fixed"> + { + halp_meta(description, "Replaces every value by Fixed value before the rest") + } fixed; + + struct : halp::hslider_f32<"Fixed value", halp::range{0., 127., 100.}> + { + halp_meta(description, "The value used when Fixed is on") + } fixed_value; + + struct : halp::range_slider_f32< + "In range", halp::range_slider_range{0., 127., {0., 127.}}> + { + halp_meta( + description, "Values are clipped to this range, then mapped to Out range") + } in_range; + + struct : halp::range_slider_f32< + "Out range", halp::range_slider_range{0., 127., {0., 127.}}> + { + halp_meta(description, "Where In range is mapped to") + } out_range; + + struct : halp::knob_f32<"Curve", halp::range{-1., 1., 0.}> + { + halp_meta( + description, + "Above 0 raises the low values, below 0 lowers them; 0 is linear") + } curve; + + struct : halp::hslider_f32<"Threshold", halp::range{0., 127., 64.}> + { + halp_meta( + description, + "Values above it are compressed or expanded by Ratio. For pitch bend it " + "is a distance to the center, 127 being a full bend.") + } threshold; + + struct : halp::knob_f32<"Ratio", halp::range{0.25, 8., 1.}> + { + halp_meta( + description, "Above 1 compresses, below 1 expands what is above Threshold") + } ratio; + + // ---- Gain ----------------------------------------------------------- + struct : halp::knob_f32<"Gain", halp::range{0., 4., 1.}> + { + halp_meta(description, "Amplification: 1 keeps the value, 2 doubles it") + } gain; + + struct : halp::hslider_f32<"Offset", halp::range{-127., 127., 0.}> + { + halp_meta(description, "Added to the value after the gain") + } offset; + + struct : halp::knob_f32<"Low keys", halp::range{0., 2., 1.}> + { + halp_meta( + description, + "Gain at note 24, for the kinds that come with a note. " + "Between 24, 60 and 96 the gains are interpolated.") + } low_gain; + + struct : halp::knob_f32<"Mid keys", halp::range{0., 2., 1.}> + { + halp_meta(description, "Gain at note 60") + } mid_gain; + + struct : halp::knob_f32<"High keys", halp::range{0., 2., 1.}> + { + halp_meta(description, "Gain at note 96") + } high_gain; + + // ---- Limits --------------------------------------------------------- + struct : halp::range_slider_f32< + "Random add", halp::range_slider_range{-64., 64., {0., 0.}}> + { + halp_meta(description, "A random amount drawn uniformly in this range is added") + } random; + + struct : halp::spinbox_i32<"Seed", halp::range{0, 10000, 0}> + { + halp_meta( + description, + "0 keeps running freely; any other value gives a reproducible sequence, " + "restarted whenever the transport rewinds or stops") + } seed; + + struct : halp::range_slider_f32< + "Clip", halp::range_slider_range{0., 127., {0., 127.}}> + { + halp_meta( + description, "The result is kept in this range. Note-on velocities never " + "go below 1, so a note is never turned into a note-off.") + } clip; + } inputs; + + struct + { + halp::midi_bus<"MIDI", libremidi::message> midi; + struct : halp::timed_callback<"Value", float> + { + halp_meta( + description, + "Each shaped value as it is sent: 0 - 127 for velocities, controllers " + "and pressure, -1 to 1 for pitch bend") + } value; + } outputs; + + //! std::random_device can allocate, block and throw: it is only used here. + MidiValueShaper() + { + std::random_device rd; + m_free_seed[0] = (uint64_t(rd()) << 32) ^ uint64_t(rd()); + m_free_seed[1] = (uint64_t(rd()) << 32) ^ uint64_t(rd()); + reseed(); + } + + //! The next tick restarts the random sequence of a seeded take. + void stop() noexcept { m_stop_requested = true; } + + using tick = halp::tick_musical; + void operator()(halp::tick_musical t) noexcept + { + // Seed 0 keeps running freely: only a seeded take restarts. + const int seed = inputs.seed; + const bool restart + = seed != m_seed + || (seed != 0 && (m_stop_requested || t.position_in_frames < m_last_position)); + m_seed = seed; + m_stop_requested = false; + if(restart && seed != 0) + reseed(); + m_last_position = t.position_in_frames; + + for(const auto& m : inputs.midi) + { + auto out = m; + const bool shaped = process(out); + if(shaped) + if(auto v = message_value(out)) + outputs.value(out.timestamp, *v); + outputs.midi.push_back(std::move(out)); + } + } + + //! Shapes one value, on the 0 - 127 scale. `note` is the note the value + //! belongs to, or -1. + double shape(double u, int note) noexcept + { + const auto& in = inputs; + const bool bipolar = in.kind.value == PitchBend; + + if(in.fixed.value) + u = in.fixed_value.value; + + // Rescale + { + const auto [ilo_f, ihi_f] = in.in_range.value; + const auto [olo_f, ohi_f] = in.out_range.value; + const double ilo = std::min(ilo_f, ihi_f), ihi = std::max(ilo_f, ihi_f); + const double olo = olo_f, ohi = ohi_f; + u = std::clamp(u, ilo, ihi); + if(ilo != 0. || ihi != 127. || olo != 0. || ohi != 127.) + u = (ihi > ilo) ? olo + (u - ilo) / (ihi - ilo) * (ohi - olo) : olo; + } + + // Curve, compression and gain act on the distance to the rest value, + // normalized so that 1 is the full range on that side. + const double origin = bipolar ? 64. : 0.; + const double span_up = bipolar ? 63. : 127.; + const double span_down = bipolar ? 64. : 127.; + double d = u - origin; + const double sign = d < 0. ? -1. : 1.; + double n = std::abs(d) / (d < 0. ? span_down : span_up); + + const double curve = std::clamp(double(in.curve.value), -1., 1.); + if(curve != 0.) + n = std::pow(std::min(n, 1.), std::pow(3., -curve)); + + const double ratio = std::clamp(double(in.ratio.value), 0.25, 8.); + const double thresh = std::clamp(double(in.threshold.value), 0., 127.) / 127.; + if(ratio != 1. && n > thresh) + n = thresh + (n - thresh) / ratio; + + n *= std::max(0., double(in.gain.value)) * key_gain(note); + + d = sign * n * (d < 0. ? span_down : span_up); + u = origin + d; + + u += in.offset.value; + + const auto [rlo_f, rhi_f] = in.random.value; + const double rlo = std::min(rlo_f, rhi_f), rhi = std::max(rlo_f, rhi_f); + if(rhi > rlo) + u += rlo + uniform() * (rhi - rlo); + else + u += rlo; + + const auto [clo_f, chi_f] = in.clip.value; + const double clo = std::min(clo_f, chi_f), chi = std::max(clo_f, chi_f); + u = std::clamp(u, clo, chi); + + return std::clamp(u, 0., 127.); + } + + //! Gain of the key-tracked bands at a note, 1 when the value has no note. + double key_gain(int note) const noexcept + { + if(note < 0) + return 1.; + const double lo = std::max(0., double(inputs.low_gain.value)); + const double mid = std::max(0., double(inputs.mid_gain.value)); + const double hi = std::max(0., double(inputs.high_gain.value)); + if(note <= 24) + return lo; + if(note <= 60) + return lo + (mid - lo) * double(note - 24) / 36.; + if(note <= 96) + return mid + (hi - mid) * double(note - 60) / 36.; + return hi; + } + + //! 14-bit pitch bend to the 0 - 127 scale: 8192 is exactly 64. + static double bend_to_scale(int v) noexcept + { + return v < 8192 ? double(v) / 8192. * 64. : 64. + double(v - 8192) / 8191. * 63.; + } + + static int scale_to_bend(double u) noexcept + { + const double v = u < 64. ? u / 64. * 8192. : 8192. + (u - 64.) / 63. * 8191.; + return std::clamp(int(std::lround(v)), 0, 16383); + } + +private: + //! Whether the message was of the kind shaped. + bool process(libremidi::message& m) noexcept + { + if(!is_channel_message(m)) + return false; + if(inputs.channel != 0 && channel_of(m) != inputs.channel) + return false; + + const int number = inputs.number; + const uint8_t status = m.bytes[0] & 0xF0; + switch(inputs.kind.value) + { + case Velocity: + if(is_note_on(m)) + { + const double u = shape(m.bytes[2], m.bytes[1]); + m.bytes[2] = uint8_t(std::clamp(int(std::lround(u)), 1, 127)); + return true; + } + break; + + // A note-on with velocity 0 is a note-off without a release velocity: + // giving it one would turn it into a note-on. + case Release: + if(status == 0x80 && m.size() >= 3) + { + m.bytes[2] = to_7bit(shape(m.bytes[2], m.bytes[1])); + return true; + } + break; + + case CC: + if(status == 0xB0 && m.size() >= 3 + && (number < 0 ? is_continuous_controller(m.bytes[1]) : m.bytes[1] == number) + && !is_all_notes_off(m)) + { + m.bytes[2] = to_7bit(shape(m.bytes[2], -1)); + return true; + } + break; + + case PitchBend: + if(status == 0xE0 && m.size() >= 3) + { + const int v = (m.bytes[2] << 7) | m.bytes[1]; + const int out = scale_to_bend(shape(bend_to_scale(v), -1)); + m.bytes[1] = uint8_t(out & 0x7F); + m.bytes[2] = uint8_t((out >> 7) & 0x7F); + return true; + } + break; + + case Pressure: + if(status == 0xD0 && m.size() >= 2) + { + m.bytes[1] = to_7bit(shape(m.bytes[1], -1)); + return true; + } + break; + + case PolyPressure: + if(status == 0xA0 && m.size() >= 3 && (number < 0 || m.bytes[1] == number)) + { + m.bytes[2] = to_7bit(shape(m.bytes[2], m.bytes[1])); + return true; + } + break; + } + return false; + } + + //! Controllers whose value is a position that can be reshaped: not bank + //! select, the RPN / NRPN selection and data bytes, nor channel mode + //! messages, whose values are numbers or commands. + static constexpr bool is_continuous_controller(int cc) noexcept + { + switch(cc) + { + case 0: + case 32: + case 6: + case 38: + return false; + default: + return !(cc >= 96 && cc <= 101) && cc < 120; + } + } + + static uint8_t to_7bit(double u) noexcept + { + return uint8_t(std::clamp(int(std::lround(u)), 0, 127)); + } + + double uniform() noexcept { return double(m_rng()) / 4294967296.0; } + + void reseed() noexcept + { + if(m_seed != 0) + m_rng.seed(uint64_t(m_seed), UINT64_C(0x9e3779b97f4a7c15)); + else + m_rng.seed(m_free_seed[0], m_free_seed[1]); + } + +public: + struct ui + { + halp_meta(name, "Midi value shaper") + halp_meta(layout, halp::layouts::hbox) + halp_meta(background, halp::colors::background_dark) + + struct + { + halp_meta(name, "Select") + halp_meta(layout, halp::layouts::vbox) + halp_meta(background, halp::colors::background_mid) + halp::item<&ins::kind> kind; + halp::item<&ins::number> number; + halp::item<&ins::channel> channel; + } select; + + struct + { + halp_meta(name, "Shape") + halp_meta(layout, halp::layouts::tabs) + halp_meta(background, halp::colors::background_darker) + + struct + { + halp_meta(name, "Range") + halp_meta(layout, halp::layouts::grid) + halp_meta(columns, 2) + halp::item<&ins::fixed> fixed; + struct + { + halp_meta(layout, halp::layouts::vbox) + static constexpr auto condition() + { + return halp::enabled_when<&ins::fixed, true>{}; + } + halp::item<&ins::fixed_value> fixed_value; + } fixed_value; + halp::item<&ins::in_range> in_range; + halp::item<&ins::out_range> out_range; + } range; + + struct + { + halp_meta(name, "Dynamics") + halp_meta(layout, halp::layouts::grid) + halp_meta(columns, 3) + halp::item<&ins::gain> gain; + halp::item<&ins::curve> curve; + halp::item<&ins::ratio> ratio; + halp::item<&ins::offset> offset; + halp::item<&ins::threshold> threshold; + } dynamics; + + struct + { + halp_meta(name, "Keys") + halp_meta(layout, halp::layouts::hbox) + halp::item<&ins::low_gain> low_gain; + halp::item<&ins::mid_gain> mid_gain; + halp::item<&ins::high_gain> high_gain; + } keys; + + struct + { + halp_meta(name, "Limits") + halp_meta(layout, halp::layouts::grid) + halp_meta(columns, 2) + halp::item<&ins::random> random; + halp::item<&ins::clip> clip; + halp::item<&ins::seed> seed; + } limits; + } shape; + }; + +private: + rnd::pcg m_rng; + uint64_t m_free_seed[2]{}; + int64_t m_last_position{}; + int m_seed{}; + bool m_stop_requested{}; +}; +} diff --git a/examples/Advanced/Patternal/Melodial.hpp b/examples/Advanced/Patternal/Melodial.hpp index 539fe5d42..90f21f58b 100644 --- a/examples/Advanced/Patternal/Melodial.hpp +++ b/examples/Advanced/Patternal/Melodial.hpp @@ -23,7 +23,7 @@ struct Processor { halp_meta(name, "Melodial") halp_meta(c_name, "melodial") - halp_meta(category, "Midi") + halp_meta(category, "Midi/Sequencers") halp_meta(manual_url, "https://ossia.io/score-docs/processes/melodial.html") halp_meta(author, "Jean-Michaël Celerier") halp_meta( diff --git a/examples/Advanced/Patternal/Patternal.hpp b/examples/Advanced/Patternal/Patternal.hpp index cfcca38dd..2a18d45c9 100644 --- a/examples/Advanced/Patternal/Patternal.hpp +++ b/examples/Advanced/Patternal/Patternal.hpp @@ -21,7 +21,7 @@ struct Processor { halp_meta(name, "Patternal") halp_meta(c_name, "patternal") - halp_meta(category, "Midi") + halp_meta(category, "Midi/Sequencers") halp_meta(manual_url, "https://ossia.io/score-docs/processes/patternal.html") halp_meta(author, "Jean-Michaël Celerier") halp_meta( diff --git a/examples/Midi/MidiGlide.hpp b/examples/Midi/MidiGlide.hpp index c5d775b2c..db3777e6f 100644 --- a/examples/Midi/MidiGlide.hpp +++ b/examples/Midi/MidiGlide.hpp @@ -4,26 +4,133 @@ #include #include +#include #include #include #include +#include +#include +#include + namespace mo { +/** + * Monophonic: one note sounds at a time, chosen among the keys held as a + * monophonic synthesizer does. + * + * - Priority: the held key that sounds, the last one pressed, the lowest or + * the highest. A key pressed that does not win is remembered. + * - On release: when the key of the sounding note is released while others + * are held, Stop ends the note and forgets the keys held, whose releases + * then do nothing; Return goes back to the held key that wins, at the + * velocity it was pressed with. + * - Transition: from one note to the next, Retrigger ends the first before + * starting the second; Legato starts the second first, which monophonic + * synthesizers in legato ("fingered") mode take as a slide: glide, and no + * new attack. + * - Latch: Toggle keeps the note sounding when its key is released, until + * another key takes over; Momentary does so while Hold is down, and the + * note latched ends when Hold goes up. + * + * The defaults, Last, Stop, Retrigger and no latch, are a plain monophonic + * legato. A note is identified by its channel and its pitch: its note-off goes + * on the channel its note-on went on, and a note-off of the same pitch on + * another channel leaves it sounding. + */ struct MidiGlide { halp_meta(name, "Midi Glide") halp_meta(c_name, "midi_glide") - halp_meta(category, "Midi"); + halp_meta(category, "Midi/Pitch"); halp_meta(author, "Jean-Michaël Celerier") - halp_meta(description, "Midi Glide") + halp_meta( + description, + "Monophonic: one note at a time, with the note priority, release, legato " + "and latch behaviours of monophonic synthesizers") halp_meta( manual_url, "https://ossia.io/score-docs/processes/midi-utilities.html#midi-glide") halp_meta(uuid, "74d26417-1ce5-4765-874e-055921f0a0bb") + halp_flag(process_exec); - struct + enum Priority + { + Last, + Low, + High + }; + enum Release + { + Stop, + Return + }; + enum Transition + { + Retrigger, + Legato + }; + enum Latch + { + NoLatch, + LatchToggle, + LatchMomentary + }; + + struct ins { halp::midi_bus<"in", libremidi::message> in; + + struct : halp::enum_t + { + halp_meta( + description, + "The held key that sounds: the last one pressed, the lowest or the highest") + } priority; + + struct : halp::enum_t + { + halp_meta( + description, + "When the sounding key is released while others are held: Stop ends " + "the note, Return goes back to the held key that wins") + struct range + { + std::string_view values[2] = {"Stop", "Return"}; + Release init = Stop; + }; + } release; + + struct : halp::enum_t + { + halp_meta( + description, + "Retrigger ends a note before starting the next; Legato starts the next " + "first, so that a monophonic synthesizer glides without a new attack") + } transition; + + struct : halp::enum_t + { + halp_meta( + description, + "Toggle: the note keeps sounding when its key is released, until another " + "key takes over. Momentary: the same, while Hold is down.") + enum widget + { + combobox + }; + struct range + { + std::string_view values[3] = {"Off", "Toggle", "Momentary"}; + Latch init = NoLatch; + }; + } latch; + + struct : halp::maintained_button<"Hold"> + { + halp_meta( + description, + "With the Momentary latch: the note keeps sounding while this is down") + } hold; } inputs; struct @@ -31,41 +138,234 @@ struct MidiGlide halp::midi_bus<"out", libremidi::message> out; } outputs; - void operator()() + struct ui { - for(const auto& note : inputs.in) + halp_meta(name, "Midi Glide") + halp_meta(layout, halp::layouts::hbox) + halp_meta(background, halp::colors::background_mid) + struct { - if(note.is_note_on_or_off()) + halp_meta(layout, halp::layouts::vbox) + halp::item<&ins::priority> priority; + halp::item<&ins::release> release; + halp::item<&ins::transition> transition; + } mono; + struct + { + halp_meta(layout, halp::layouts::vbox) + halp::item<&ins::latch> latch; + struct { - auto vel = note.bytes[2]; - if(vel == 0 || note.get_message_type() == libremidi::message_type::NOTE_OFF) + halp_meta(layout, halp::layouts::vbox) + static constexpr auto condition() { - if(note.bytes[1] == current_note) - { - outputs.out.push_back(libremidi::channel_events::note_off(note.get_channel(), current_note, 0)); - outputs.out.back().timestamp = note.timestamp; - current_note = 255; - } + return halp::enabled_when<&ins::latch, int(LatchMomentary)>{}; + } + halp::item<&ins::hold> hold; + } hold; + } latch; + }; + + //! The sounding note ends at the start of the next tick. + void stop() noexcept { m_stop_requested = true; } + + void operator()() noexcept + { + if(m_stop_requested) + { + m_stop_requested = false; + end_current(0, 0); + m_held_count = 0; + } + + // Hold going up ends the note it latched, unless its key is down. + const bool latching = this->latching(); + if(m_latching && !latching && sounding() && !is_held(m_current)) + end_current(0, 0); + m_latching = latching; + + for(const auto& m : inputs.in) + { + if(m.is_note_on_or_off()) + { + const key k{uint8_t(m.get_channel()), m.bytes[1]}; + const uint8_t vel = m.bytes[2]; + const bool release + = vel == 0 || m.get_message_type() == libremidi::message_type::NOTE_OFF; + if(release) + { + const bool is_off = m.get_message_type() == libremidi::message_type::NOTE_OFF; + key_up(k, is_off ? vel : 0, m.timestamp); } else { - // Send a note off for the current note - if(current_note != 255) - { - outputs.out.push_back(libremidi::channel_events::note_off(note.get_channel(), current_note, 0)); - outputs.out.back().timestamp = note.timestamp; - } - outputs.out.push_back(note); - current_note = note.bytes[1]; + key_down(k, vel, m.timestamp); } } else { - outputs.out.push_back(note); + // All sound off and all notes off end the notes at the receiver, and + // release the keys of the channel. + if(m.get_message_type() == libremidi::message_type::CONTROL_CHANGE + && (m.bytes[1] == 120 || m.bytes[1] == 123)) + all_notes_off(m.get_channel()); + outputs.out.push_back(m); + } + } + } + + //! Whether a note sounds at the output. + bool sounding() const noexcept { return m_current.pitch != no_note; } + +private: + static constexpr uint8_t no_note = 255; + static constexpr int max_held = 64; + + struct key + { + uint8_t channel{1}; + uint8_t pitch{no_note}; + bool operator==(const key&) const noexcept = default; + }; + struct held_key + { + key k; + uint8_t velocity{}; + }; + + bool latching() const noexcept + { + return inputs.latch.value == LatchToggle + || (inputs.latch.value == LatchMomentary && inputs.hold.value); + } + + bool is_held(key k) const noexcept + { + for(int i = 0; i < m_held_count; i++) + if(m_held[i].k == k) + return true; + return false; + } + + void forget(key k) noexcept + { + int j = 0; + for(int i = 0; i < m_held_count; i++) + if(!(m_held[i].k == k)) + m_held[j++] = m_held[i]; + m_held_count = j; + } + + //! The held key that sounds; the latest pressed among equal pitches. + const held_key* winner() const noexcept + { + const held_key* res{}; + for(int i = 0; i < m_held_count; i++) + { + const auto& h = m_held[i]; + switch(inputs.priority.value) + { + case Last: + res = &h; + break; + case Low: + if(!res || h.k.pitch <= res->k.pitch) + res = &h; + break; + case High: + if(!res || h.k.pitch >= res->k.pitch) + res = &h; + break; + } + } + return res; + } + + void send_on(const held_key& h, int64_t ts) noexcept + { + auto m = libremidi::channel_events::note_on(h.k.channel, h.k.pitch, h.velocity); + m.timestamp = ts; + outputs.out.push_back(m); + } + + //! The sounding note goes to `next`, ended first or after as Transition says. + void move_to(const held_key& next, int64_t ts) noexcept + { + // The same note cannot overlap itself at the receiver + if(!sounding() || inputs.transition.value == Retrigger || m_current == next.k) + { + end_current(0, ts); + send_on(next, ts); + } + else + { + send_on(next, ts); + end_current(0, ts); + } + m_current = next.k; + } + + void key_down(key k, uint8_t velocity, int64_t ts) noexcept + { + forget(k); + if(m_held_count == max_held) + { + for(int i = 0; i + 1 < max_held; i++) + m_held[i] = m_held[i + 1]; + m_held_count--; + } + m_held[m_held_count++] = {k, velocity}; + + const held_key* w = winner(); + if(w && (w->k == k || !sounding())) + move_to(*w, ts); + } + + void key_up(key k, uint8_t velocity, int64_t ts) noexcept + { + forget(k); + if(!sounding() || !(m_current == k)) + return; + + if(inputs.release.value == Return) + { + if(const held_key* w = winner()) + { + move_to(*w, ts); + return; } } + // Stop: the keys still down do not come back. + m_held_count = 0; + if(!latching()) + end_current(velocity, ts); + } + + void all_notes_off(int channel) noexcept + { + int j = 0; + for(int i = 0; i < m_held_count; i++) + if(m_held[i].k.channel != channel) + m_held[j++] = m_held[i]; + m_held_count = j; + if(m_current.channel == channel) + m_current.pitch = no_note; + } + + void end_current(uint8_t velocity, int64_t ts) noexcept + { + if(!sounding()) + return; + outputs.out.push_back( + libremidi::channel_events::note_off(m_current.channel, m_current.pitch, velocity)); + outputs.out.back().timestamp = ts; + m_current.pitch = no_note; } - uint8_t current_note{255}; + std::array m_held{}; + int m_held_count{}; + key m_current{}; + bool m_latching{}; + bool m_stop_requested{}; }; } diff --git a/examples/Midi/MidiSyncInput.hpp b/examples/Midi/MidiSyncInput.hpp index e84b19bfb..4de1b66c0 100644 --- a/examples/Midi/MidiSyncInput.hpp +++ b/examples/Midi/MidiSyncInput.hpp @@ -1,16 +1,21 @@ #pragma once /* SPDX-License-Identifier: GPL-3.0-or-later */ +#include #include +#include #include #include +#include #include #include #include #include #include +#include #include +#include #include #include @@ -31,6 +36,7 @@ struct MIDISyncIn halp_meta(description, "Extract timecode from MIDI Time Code messages") halp_meta(manual_url, "https://ossia.io/score-docs/processes/midi-timecode-input.html") halp_meta(uuid, "f8c7e2a3-9b4d-4e5f-a1c2-3d4e5f6a7b8c") + halp_flag(process_exec); enum class FrameRate { @@ -57,24 +63,67 @@ struct MIDISyncIn MTC_With_Clock_Interpolation // Use MTC with clock interpolation (default) }; - struct + struct ins { halp::midi_bus<"MIDI in", libremidi::message> midi_in; // Sync configuration - halp::combobox_t<"Sync Mode", SyncMode> sync_mode{ - SyncMode::MTC_With_Clock_Interpolation}; + struct : halp::combobox_t<"Sync Mode", SyncMode> + { + struct range + { + std::string_view values[3]{"MTC only", "Clock only", "MTC and clock"}; + SyncMode init{SyncMode::MTC_With_Clock_Interpolation}; + }; + } sync_mode{{{SyncMode::MTC_With_Clock_Interpolation}}}; // MTC configuration halp::spinbox_i32<"Offset (seconds)", halp::irange{-128000, 128000, 0}> offset{0}; halp::combobox_t<"Output Format", OutputFormat> format{OutputFormat::Seconds}; - halp::combobox_t<"Framerate", FrameRate> framerate{FrameRate::Auto}; + struct : halp::combobox_t<"Framerate", FrameRate> + { + struct range + { + std::string_view values[5]{"24", "25", "29.97", "30", "Auto"}; + FrameRate init{FrameRate::Auto}; + }; + } framerate{{{FrameRate::Auto}}}; // Clock configuration halp::spinbox_f32<"Base Tempo", halp::range{20.0, 300.0, 120.0}> base_tempo{120.0}; halp::knob_f32<"Clock filter", halp::range{0., 1., 0.8}> clock_filter{0.8}; halp::spinbox_i32<"Song Position", halp::irange{0, 16383, 0}> initial_song_position{ 0}; + + // Clock out processing + struct : halp::spinbox_i32<"Clock divide", halp::irange{1, 48, 1}> + { + halp_meta( + description, + "Clock out passes 1 of every N clocks, counted from each Start or from " + "the position a Song Position Pointer gives") + } clock_divide; + + struct : halp::spinbox_i32<"Start shift", halp::irange{-48, 48, 0}> + { + halp_meta( + description, + "At each Start, Clock out drops this many clocks (positive, later) or " + "inserts them right after the Start (negative, earlier)") + } start_shift; + + struct : halp::spinbox_f32<"Clock delay (ms)", halp::range{0., 1000., 0.}> + { + halp_meta(description, "Delays everything Clock out sends") + } clock_delay; + + struct : halp::toggle<"Pass SPP", halp::default_on_toggle> + { + halp_meta( + description, + "Clock out passes Song Position Pointer messages, counted in the " + "divided and shifted clock") + } pass_spp; } inputs; struct @@ -88,8 +137,78 @@ struct MIDISyncIn halp::val_port<"MTC Active", bool> mtc_active{false}; halp::val_port<"Clock Active", bool> clock_active{false}; + + struct : halp::midi_bus<"Clock out", libremidi::message> + { + halp_meta( + description, + "The incoming clock, Start, Stop, Continue and Song Position, divided, " + "shifted and delayed") + } clock_out; } outputs; + struct ui + { + halp_meta(name, "MIDI Sync In") + halp_meta(layout, halp::layouts::hbox) + halp_meta(background, halp::colors::background_dark) + + struct + { + halp_meta(name, "Decode") + halp_meta(layout, halp::layouts::section) + halp_meta(background, halp::colors::background_mid) + halp::item<&ins::sync_mode> sync_mode; + struct + { + halp_meta(layout, halp::layouts::hbox) + struct + { + halp_meta(layout, halp::layouts::vbox) + halp::item<&ins::offset> offset; + halp::item<&ins::format> format; + halp::item<&ins::framerate> framerate; + } timecode; + struct + { + halp_meta(layout, halp::layouts::vbox) + struct + { + halp_meta(layout, halp::layouts::vbox) + static constexpr auto condition() + { + return halp::enabled_when< + &ins::sync_mode, SyncMode::Clock_Only, + SyncMode::MTC_With_Clock_Interpolation>{}; + } + halp::item<&ins::base_tempo> base_tempo; + halp::item<&ins::clock_filter> clock_filter; + } tempo; + struct + { + halp_meta(layout, halp::layouts::vbox) + static constexpr auto condition() + { + return halp::enabled_when<&ins::sync_mode, SyncMode::Clock_Only>{}; + } + halp::item<&ins::initial_song_position> initial_song_position; + } song_position; + } clock; + } columns; + } decode; + + struct + { + halp_meta(name, "Clock out") + halp_meta(layout, halp::layouts::section) + halp_meta(background, halp::colors::background_mid) + halp::item<&ins::clock_divide> clock_divide; + halp::item<&ins::start_shift> start_shift; + halp::item<&ins::clock_delay> clock_delay; + halp::item<&ins::pass_spp> pass_spp; + } clock_out; + }; + // MTC state tracking struct MTCState { @@ -122,9 +241,13 @@ struct MIDISyncIn // Clock tick tracking static constexpr size_t max_interval_history = 24; // 1 beat worth at 24 PPQN - std::chrono::steady_clock::time_point last_clock_tick; - ossia::small_pod_vector - tick_intervals; // Recent tick intervals for tempo estimation + //! Date of the last clock, in frames since the object started: from the + //! messages' timestamps, not from when the buffer is processed. + int64_t last_clock_tick = 0; + bool has_clock_tick = false; + //! Recent tick intervals for tempo estimation, never more than + //! max_interval_history: it does not allocate. + ossia::small_pod_vector tick_intervals; double estimated_tempo = 120.0; double filtered_tempo = 120.0; uint32_t ticks_since_mtc = 0; @@ -140,6 +263,7 @@ struct MIDISyncIn active = false; ticks_since_mtc = 0; tick_intervals.clear(); + has_clock_tick = false; estimated_tempo = 120.0; filtered_tempo = 120.0; @@ -210,9 +334,9 @@ struct MIDISyncIn case 0b01: return 25.0; case 0b10: - return 30.0; - case 0b11: return 29.97; + case 0b11: + return 30.0; default: return 30.0; } @@ -227,9 +351,9 @@ struct MIDISyncIn case 0b01: return FrameRate::FPS_25; case 0b10: - return FrameRate::FPS_30; - case 0b11: return FrameRate::FPS_2997; + case 0b11: + return FrameRate::FPS_30; default: return FrameRate::FPS_30; } @@ -323,6 +447,10 @@ struct MIDISyncIn mtc_state.has_valid_time = true; + // The last piece arrives two frames after the time the eight of them + // carry: the position is that much later + mtc_state.frames += 2; + // Reset clock interpolation when we get a new MTC frame clock_interp.last_mtc_seconds = to_seconds(mtc_state); clock_interp.last_mtc_timestamp = std::chrono::steady_clock::now(); @@ -336,7 +464,6 @@ struct MIDISyncIn // Full MTC message format: F0 7F 7F 01 01 hh mm ss ff F7 if(msg.size() >= 10 && msg.bytes[0] == 0xF0 && // SysEx start msg.bytes[1] == 0x7F && // Universal Real Time - msg.bytes[2] == 0x7F && // Device ID (all devices) msg.bytes[3] == 0x01 && // MTC msg.bytes[4] == 0x01) // Full message { @@ -354,6 +481,18 @@ struct MIDISyncIn mtc_state.has_valid_time = true; mtc_state.received_full_frame = true; + + // A full frame is a locate: quarter frames received before it describe + // another position, and must not complete a sequence after it. + mtc_state.quarter_frames = {}; + mtc_state.assembling = false; + mtc_state.quarter_frame_index = 0; + + // Clocks received before it do not move the new position. + clock_interp.last_mtc_seconds = to_seconds(mtc_state); + clock_interp.last_mtc_timestamp = std::chrono::steady_clock::now(); + clock_interp.ticks_since_mtc = 0; + update_outputs(); } } @@ -434,24 +573,20 @@ struct MIDISyncIn } } - void process_clock_interpolation() + //! `at`: the clock's date in frames since the object started. + void process_clock_interpolation(int64_t at) { - auto now = std::chrono::steady_clock::now(); - // Calculate interval from last clock tick - if(clock_interp.last_clock_tick.time_since_epoch().count() > 0) + if(clock_interp.has_clock_tick) { - auto interval - = std::chrono::duration(now - clock_interp.last_clock_tick).count(); + const double rate = sample_rate > 0. ? sample_rate : 48000.; + const double interval = double(at - clock_interp.last_clock_tick) / rate; - // Store interval for tempo estimation - clock_interp.tick_intervals.push_back(interval); - - // Keep only recent history - while(clock_interp.tick_intervals.size() > clock_interp.max_interval_history) - { - clock_interp.tick_intervals.erase(clock_interp.tick_intervals.begin()); - } + // Keep only recent history, without growing past the inline storage + auto& iv = clock_interp.tick_intervals; + if(iv.size() >= clock_interp.max_interval_history) + iv.erase(iv.begin()); + iv.push_back(interval); // Estimate tempo from tick intervals if(clock_interp.tick_intervals.size() >= 2) @@ -484,7 +619,8 @@ struct MIDISyncIn clock_interp.estimated_tempo = inputs.base_tempo.value; } - clock_interp.last_clock_tick = now; + clock_interp.last_clock_tick = at; + clock_interp.has_clock_tick = true; clock_interp.active = true; // Handle based on sync mode @@ -534,7 +670,139 @@ struct MIDISyncIn } } - void operator()() + // Clock out state + static constexpr int max_delayed = 512; + mtk::event_queue clock_queue; + //! Frames elapsed since the object started: the clock of clock_queue. + int64_t clock_now = 0; + double sample_rate = 0.; + int clock_counter = 0; + int clocks_to_drop = 0; + std::atomic_bool stop_requested{false}; + + void prepare(halp::setup s) noexcept + { + if(s.rate > 0.) + sample_rate = s.rate; + } + + void stop() noexcept { stop_requested.store(true, std::memory_order_release); } + + void schedule_clock_out(int64_t at, const libremidi::message& msg) noexcept + { + clock_queue.schedule(at, msg); + } + + /** + * Song Position: the divider and the shift take the phase they have at that + * position, as if the clock had run from Start, and the position goes out + * in sixteenths of the clock out, rounded down when it falls between two. + */ + void song_position_out(int64_t at, const libremidi::message& msg) noexcept + { + if(msg.size() < 3) + return; + const int n = std::max(1, inputs.clock_divide.value); + const int64_t spp = int64_t(msg.bytes[1] & 0x7F) | (int64_t(msg.bytes[2] & 0x7F) << 7); + // Clocks from where the divider starts counting to the position + const int64_t c = spp * 6 - inputs.start_shift.value; + clock_counter = c > 0 ? int(c % n) : 0; + clocks_to_drop = c < 0 ? int(-c) : 0; + + if(!inputs.pass_spp.value) + return; + // The clocks out before the position: the ones on k % n == 0, k < c + const int64_t out_clocks = c > 0 ? (c + n - 1) / n : 0; + const int64_t out_spp = std::min(out_clocks / 6, 16383); + libremidi::message m; + m.bytes = {0xF2, uint8_t(out_spp & 0x7F), uint8_t((out_spp >> 7) & 0x7F)}; + schedule_clock_out(at, m); + } + + //! A clock that survived Start shift goes through the divider. + void divide_clock(int64_t at, const libremidi::message& msg) noexcept + { + const int n = std::max(1, inputs.clock_divide.value); + if(clock_counter % n == 0) + schedule_clock_out(at, msg); + clock_counter = (clock_counter + 1) % n; + } + + void process_clock_out(const libremidi::message& msg, int64_t at) noexcept + { + if(msg.size() < 1) + return; + + switch(msg.bytes[0]) + { + case 0xFA: { + clock_counter = 0; + clocks_to_drop = std::max(0, inputs.start_shift.value); + schedule_clock_out(at, msg); + + libremidi::message clk; + clk.bytes = {0xF8}; + for(int i = 0; i < -inputs.start_shift.value; i++) + divide_clock(at, clk); + break; + } + case 0xFB: + case 0xFC: + schedule_clock_out(at, msg); + break; + case 0xF2: + song_position_out(at, msg); + break; + case 0xF8: + if(clocks_to_drop > 0) + clocks_to_drop--; + else + divide_clock(at, msg); + break; + default: + break; + } + } + + //! On stop the delayed transport messages go out at once and the delayed + //! clocks are dropped, so that a delayed Stop is not lost. + void flush_clock_out() noexcept + { + clock_queue.remove_if([this](const auto& ev) { + if(ev.msg.size() > 0 && ev.msg.bytes[0] != 0xF8) + { + auto m = ev.msg; + m.timestamp = 0; + outputs.clock_out.push_back(std::move(m)); + } + return true; + }); + clock_counter = 0; + clocks_to_drop = 0; + } + + void run_clock_out(int frames) noexcept + { + if(stop_requested.exchange(false, std::memory_order_acq_rel)) + flush_clock_out(); + + const int64_t delay + = sample_rate > 0. ? mtk::seconds_to_frames( + double(inputs.clock_delay.value) / 1000., sample_rate) + : 0; + const int64_t last = frames > 0 ? frames - 1 : 0; + for(const auto& msg : inputs.midi_in) + process_clock_out( + msg, clock_now + std::clamp(msg.timestamp, 0, last) + delay); + + clock_queue.release(clock_now, frames, [this](libremidi::message&& m) { + outputs.clock_out.push_back(std::move(m)); + }); + if(frames > 0) + clock_now += frames; + } + + void operator()(int frames) { // Process incoming MIDI messages for(const auto& msg : inputs.midi_in) @@ -548,7 +816,7 @@ struct MIDISyncIn process_quarter_frame(msg.bytes[1]); } // Check for Full Frame MTC (SysEx) - else if(msg.is_meta_event() && msg.size() >= 10) + else if(msg.size() >= 10 && msg.bytes[0] == 0xF0) { process_full_frame(msg); } @@ -593,7 +861,8 @@ struct MIDISyncIn // Process clock tick based on mode if(inputs.sync_mode.value != SyncMode::MTC_Only) { - process_clock_interpolation(); + process_clock_interpolation( + clock_now + std::clamp(msg.timestamp, 0, std::max(frames - 1, 0))); } break; @@ -611,6 +880,8 @@ struct MIDISyncIn // Check for timeout check_timeout(); + + run_clock_out(frames); } }; diff --git a/include/avnd/binding/ossia/current_buffer_midi.hpp b/include/avnd/binding/ossia/current_buffer_midi.hpp index 377dce77f..8e9fc488f 100644 --- a/include/avnd/binding/ossia/current_buffer_midi.hpp +++ b/include/avnd/binding/ossia/current_buffer_midi.hpp @@ -10,7 +10,12 @@ namespace oscr { -// This is a value property, not a presence-only flag: explicitly false opts out. +// A node runs once per token it is given, and a buffer can hold several: +// a process looping inside the buffer runs up to its loop point then from its +// start, a process stopped inside it runs its last partial tick then an empty +// one. The MIDI outlets keep what all the runs of a buffer sent, unless the +// object sets local_midi_tick_batch to false: each run then replaces what the +// previous one sent. template inline constexpr bool use_local_midi_tick_batch = [] { if constexpr(requires { T::local_midi_tick_batch(); }) @@ -18,9 +23,22 @@ inline constexpr bool use_local_midi_tick_batch = [] { else if constexpr(requires { T::local_midi_tick_batch; }) return bool(T::local_midi_tick_batch); else - return avnd::annotated_or_empty("local_midi_tick_batch") == "true"; + return avnd::annotated_or_empty("local_midi_tick_batch") != "false"; }(); +// Packets per MIDI output reserved for a buffer: local_midi_tick_batch_reserve, +// or the buffer size. +template +std::size_t local_midi_tick_batch_reserve(std::size_t buffer_size) noexcept +{ + if constexpr(requires { T::local_midi_tick_batch_reserve(); }) + return T::local_midi_tick_batch_reserve(); + else if constexpr(requires { T::local_midi_tick_batch_reserve; }) + return T::local_midi_tick_batch_reserve; + else + return buffer_size; +} + // Already-emitted packets for one graph execution, not pending processor events. template struct current_buffer_midi diff --git a/include/avnd/binding/ossia/from_value.hpp b/include/avnd/binding/ossia/from_value.hpp index 7649ba5f4..5188cf585 100644 --- a/include/avnd/binding/ossia/from_value.hpp +++ b/include/avnd/binding/ossia/from_value.hpp @@ -14,6 +14,7 @@ #include #include +#include #include #include #include @@ -1001,6 +1002,16 @@ struct from_ossia_value_impl break; } } + //! Fixed-capacity containers keep the first elements of a vecNf + template + static void assign_clamped(T& f, const std::array& v) + { + std::size_t n = N; + if constexpr(requires { T::static_capacity; }) + n = std::min(n, std::size_t(T::static_capacity)); + f.assign(v.begin(), v.begin() + n); + } + template requires(!avnd::string_ish) bool operator()(const ossia::value& src, T& f) @@ -1053,7 +1064,7 @@ struct from_ossia_value_impl if constexpr(float_compatible) { ossia::vec2f v = *src.target(); - f.assign(v.begin(), v.end()); + assign_clamped(f, v); return true; } else @@ -1066,7 +1077,7 @@ struct from_ossia_value_impl if constexpr(float_compatible) { ossia::vec3f v = *src.target(); - f.assign(v.begin(), v.end()); + assign_clamped(f, v); return true; } else @@ -1079,7 +1090,7 @@ struct from_ossia_value_impl if constexpr(float_compatible) { ossia::vec4f v = *src.target(); - f.assign(v.begin(), v.end()); + assign_clamped(f, v); return true; } else @@ -1090,8 +1101,12 @@ struct from_ossia_value_impl } case ossia::val_type::LIST: { const std::vector& vl = *src.target>(); - f.resize(vl.size()); - for(int i = 0, n = vl.size(); i < n; i++) + std::size_t n = vl.size(); + // Fixed-capacity containers keep their first elements + if constexpr(requires { T::static_capacity; }) + n = std::min(n, std::size_t(T::static_capacity)); + f.resize(n); + for(std::size_t i = 0; i < n; i++) (*this)(vl[i], f[i]); return true; break; @@ -1437,7 +1452,7 @@ bool from_ossia_value_rec(const ossia::value& src, T& dst) } template - requires(std::is_aggregate_v && !avnd::tensor_like) + requires(std::is_aggregate_v && !avnd::tensor_like && !avnd::grid_value_ish) bool from_ossia_value(const ossia::value& src, T& dst) { using type = std::decay_t; @@ -1485,6 +1500,38 @@ bool from_ossia_value(const ossia::value& src, T& dst) } } +//! A grid is {value, unit}, or {value, unit, offset}. A plain number (a +//! cable, an OSC message) is seconds, unit 0. +template +inline bool from_ossia_value(const ossia::value& src, T& dst) +{ + using unit_type = decltype(dst.unit); + switch(src.get_type()) + { + case ossia::val_type::FLOAT: + case ossia::val_type::INT: + case ossia::val_type::BOOL: + dst.value = ossia::convert(src); + dst.unit = unit_type(0); + dst.offset = 0; + return true; + case ossia::val_type::VEC3F: { + const auto& v = *src.target(); + dst.value = v[0]; + dst.unit = unit_type(int(std::lround(v[1]))); + dst.offset = v[2]; + return true; + } + default: { + const auto v = ossia::convert(src); + dst.value = v[0]; + dst.unit = unit_type(int(std::lround(v[1]))); + dst.offset = 0; + return true; + } + } +} + template OSSIA_INLINE bool from_ossia_value(const ossia::value& src, T& dst) { diff --git a/include/avnd/binding/ossia/midi_input_assembly.hpp b/include/avnd/binding/ossia/midi_input_assembly.hpp new file mode 100644 index 000000000..df1237184 --- /dev/null +++ b/include/avnd/binding/ossia/midi_input_assembly.hpp @@ -0,0 +1,32 @@ +#pragma once +// SPDX-License-Identifier: GPL-3.0-or-later +#include +#include + +#include + +namespace oscr +{ +// Bytes of the longest system-exclusive message an object taking MIDI 1 is +// given, F0 and F7 included; a longer one is dropped. +inline constexpr std::size_t midi_input_sysex_capacity = 4096; + +// A MIDI inlet carries UMP: a system-exclusive message is a sequence of SysEx7 +// packets, which can be spread over the runs of a buffer and over buffers. Each +// MIDI input keeps the message it is assembling between runs; one left +// unfinished is dropped when the next one starts. +template +struct midi_input_assembly +{ + using stream = libremidi::ump_to_midi1_stream; + std::array::size> inputs; + + template + stream& input(avnd::field_index) noexcept + { + constexpr auto index = avnd::midi_input_introspection::field_index_to_index( + avnd::field_index{}); + return inputs[std::size_t(index)]; + } +}; +} diff --git a/include/avnd/binding/ossia/node.hpp b/include/avnd/binding/ossia/node.hpp index be1c43da2..e3559f35e 100644 --- a/include/avnd/binding/ossia/node.hpp +++ b/include/avnd/binding/ossia/node.hpp @@ -12,6 +12,7 @@ class safe_node; #include #include #include +#include #include #include #include @@ -150,6 +151,7 @@ class safe_node_base_base : public ossia::nonowning_graph_node AVND_NO_UNIQUE_ADDRESS avnd::midi_storage midi_buffers; AVND_NO_UNIQUE_ADDRESS oscr::current_buffer_midi midi_tick_batch; + AVND_NO_UNIQUE_ADDRESS oscr::midi_input_assembly midi_input_assembly; void begin_execution() noexcept override { @@ -491,14 +493,17 @@ class safe_node_base : public safe_node_base_base { this->midi_buffers.reserve_space(this->impl, this->buffer_size); - if constexpr(oscr::use_local_midi_tick_batch) + if constexpr( + oscr::use_local_midi_tick_batch + && avnd::midi_output_introspection::size > 0) { - if constexpr(requires { T::local_midi_tick_batch_reserve(); }) - this->midi_tick_batch.reserve(T::local_midi_tick_batch_reserve()); - else if constexpr(requires { T::local_midi_tick_batch_reserve; }) - this->midi_tick_batch.reserve(T::local_midi_tick_batch_reserve); - else - this->midi_tick_batch.reserve(this->buffer_size); + const auto reserve + = oscr::local_midi_tick_batch_reserve(std::size_t(this->buffer_size)); + this->midi_tick_batch.reserve(reserve); + // Each run copies the whole batch to the outlet + for(auto* outlet : this->m_outlets) + if(auto* port = outlet->template target()) + port->messages.reserve(reserve); } this->control_buffers.reserve_space(this->impl, this->buffer_size); this->spectrums.reserve_space(this->impl, this->buffer_size); diff --git a/include/avnd/binding/ossia/port_run_preprocess.hpp b/include/avnd/binding/ossia/port_run_preprocess.hpp index 9d2ca00b0..201f962d6 100644 --- a/include/avnd/binding/ossia/port_run_preprocess.hpp +++ b/include/avnd/binding/ossia/port_run_preprocess.hpp @@ -396,8 +396,12 @@ struct process_before_run ctrl.ports.resize(ports.size()); } + // An object taking UMP gets the packets as they are. One taking MIDI 1 gets + // a message when a packet completes one: a system-exclusive message once + // its last SysEx7 packet arrives, stamped with that packet's time. template - bool copy_midi_input(Message& dst, const libremidi::ump& src) const noexcept + bool copy_midi_input( + Message& dst, const libremidi::ump& src, auto& midi1_stream) const noexcept { if constexpr(std::is_same_v) { @@ -405,11 +409,15 @@ struct process_before_run } else { - auto converted = libremidi::midi1_from_ump(src); + const auto converted = midi1_stream.feed(src); if(converted.empty()) return false; if constexpr(requires { dst.bytes.resize(converted.size()); }) + { + if(converted.size() > dst.bytes.max_size()) + return false; dst.bytes.resize(converted.size()); + } else if(converted.size() > std::size(dst.bytes)) return false; std::copy(converted.begin(), converted.end(), std::begin(dst.bytes)); @@ -430,10 +438,11 @@ struct process_before_run storage.resize(port.data.messages.size()); ctrl.midi_messages = storage.data(); ctrl.size = 0; + auto& midi1_stream = self.midi_input_assembly.input(avnd::field_index{}); for(const auto& msg : port.data.messages) { if(msg.timestamp >= start && msg.timestamp < start + frames - && copy_midi_input(storage[ctrl.size], msg)) + && copy_midi_input(storage[ctrl.size], msg, midi1_stream)) ++ctrl.size; } } @@ -536,12 +545,13 @@ struct process_before_run ctrl.midi_messages.clear(); ctrl.midi_messages.reserve(port.data.messages.size()); + auto& midi1_stream = self.midi_input_assembly.input(avnd::field_index{}); for(const auto& msg_in : port.data.messages) { if(msg_in.timestamp >= start && msg_in.timestamp < start + frames) { midi_msg_type msg{}; - if(copy_midi_input(msg, msg_in)) + if(copy_midi_input(msg, msg_in, midi1_stream)) ctrl.midi_messages.push_back(std::move(msg)); } } diff --git a/include/avnd/binding/ossia/port_setup.hpp b/include/avnd/binding/ossia/port_setup.hpp index 8d44ce3e5..959fa45ea 100644 --- a/include/avnd/binding/ossia/port_setup.hpp +++ b/include/avnd/binding/ossia/port_setup.hpp @@ -436,6 +436,15 @@ struct setup_value_port port.domain = setup_value_port::range_to_domain(); } + //! {value, unit}: see halp::grid_value + template + static void setup(ossia::value_port& port) + { + setup_port_is_event(port); + port.type = ossia::val_type::VEC2F; + port.domain = {}; + } + template requires(!avnd::rgba_parameter) static void setup(ossia::value_port& port) diff --git a/include/avnd/binding/ossia/to_value.hpp b/include/avnd/binding/ossia/to_value.hpp index 950e081b7..fc58a630b 100644 --- a/include/avnd/binding/ossia/to_value.hpp +++ b/include/avnd/binding/ossia/to_value.hpp @@ -582,6 +582,15 @@ ossia::value to_ossia_value(const avnd::enum_ish auto& v) return static_cast(v); } +//! {value, unit}; the offset only when there is one. +template +ossia::value to_ossia_value(const T& v) +{ + if(v.offset != 0) + return ossia::vec3f{float(v.value), float(int(v.unit)), float(v.offset)}; + return ossia::vec2f{float(v.value), float(int(v.unit))}; +} + inline ossia::value to_ossia_value(const bool& v) { return v; diff --git a/include/avnd/concepts/control.hpp b/include/avnd/concepts/control.hpp index 023cfc1ad..402a3e867 100644 --- a/include/avnd/concepts/control.hpp +++ b/include/avnd/concepts/control.hpp @@ -48,6 +48,8 @@ template concept string_control_port = string_parameter && control_port; template concept time_control_port = float_control_port && requires { T::time_chooser; }; +template +concept grid_control_port = grid_parameter && requires { T::widget::grid_chooser; }; template concept int_value_port = int_parameter && !control_port; diff --git a/include/avnd/concepts/parameter.hpp b/include/avnd/concepts/parameter.hpp index 375f35511..cb275c78c 100644 --- a/include/avnd/concepts/parameter.hpp +++ b/include/avnd/concepts/parameter.hpp @@ -143,6 +143,17 @@ concept rgba_value = requires(T t) { template concept rgba_parameter = parameter_port && rgba_value; +//! A quantization grid, see halp::grid_value: a value in some unit (an enum), +//! and an offset. +template +concept grid_value_ish = requires(T t) { + t.unit; + t.value; + t.offset; +} && std::is_enum_v; +template +concept grid_parameter = parameter_port && grid_value_ish; + template concept parameter_with_minmax_range_ignore_init = parameter_port && AVND_REQUIREMENT_ON_MEMBER(C, range, min) diff --git a/include/avnd/wrappers/widgets.hpp b/include/avnd/wrappers/widgets.hpp index b358245f0..7613f845c 100644 --- a/include/avnd/wrappers/widgets.hpp +++ b/include/avnd/wrappers/widgets.hpp @@ -30,6 +30,14 @@ enum class widget_type lineedit, combobox, string_list, + //! Rows of numbers, see halp::value_list + value_list, + //! Degree to degree map, see halp::note_matrix + note_matrix, + //! The 16 MIDI channels, see halp::channel_mask + channel_mask, + //! Named toggles, see halp::toggle_grid + toggle_grid, choices, xy, xy_spinbox, @@ -39,6 +47,8 @@ enum class widget_type xyzw_spinbox, color, time_chooser, + //! A quantization grid, see halp::grid_chooser + grid_chooser, folder, bargraph, range_slider, @@ -87,6 +97,14 @@ struct widget_reflection return "combobox"; case widget_type::string_list: return "string_list"; + case widget_type::value_list: + return "value_list"; + case widget_type::note_matrix: + return "note_matrix"; + case widget_type::channel_mask: + return "channel_mask"; + case widget_type::toggle_grid: + return "toggle_grid"; case widget_type::choices: return "choices"; case widget_type::xy: @@ -105,6 +123,8 @@ struct widget_reflection return "color"; case widget_type::time_chooser: return "time_chooser"; + case widget_type::grid_chooser: + return "grid_chooser"; case widget_type::folder: return "folder"; case widget_type::file: @@ -222,6 +242,10 @@ consteval auto get_widget() { return time_chooser_reflection{slider_orientation::horizontal}; } + else if constexpr(requires { T::widget::grid_chooser; }) + { + return widget_reflection{widget_type::grid_chooser}; + } else if constexpr(requires { T::widget::folder; }) { return widget_reflection{widget_type::folder}; @@ -234,6 +258,22 @@ consteval auto get_widget() { return widget_reflection{widget_type::string_list}; } + else if constexpr(requires { T::widget::channel_mask; }) + { + return widget_reflection{widget_type::channel_mask}; + } + else if constexpr(requires { T::widget::toggle_grid; }) + { + return widget_reflection{widget_type::toggle_grid}; + } + else if constexpr(requires { T::widget::note_matrix; }) + { + return widget_reflection{widget_type::note_matrix}; + } + else if constexpr(requires { T::widget::value_list; }) + { + return widget_reflection{widget_type::value_list}; + } else if constexpr(requires { T::widget::multi_slider; }) { return widget_reflection>{widget_type::multi_slider}; diff --git a/include/halp/channel_mask.hpp b/include/halp/channel_mask.hpp new file mode 100644 index 000000000..3a5c10a34 --- /dev/null +++ b/include/halp/channel_mask.hpp @@ -0,0 +1,42 @@ +#pragma once +#include +#include + +#include +#include +#include + +namespace halp +{ +// Which of the 16 MIDI channels are on: value[0] is channel 1. Init is a bit +// mask, bit 0 for channel 1. Hosts draw a toggle per channel. +template +struct channel_mask +{ + static clang_buggy_consteval auto name() { return std::string_view{lit.value}; } + enum class widget + { + channel_mask + }; + + static constexpr std::array init() noexcept + { + std::array res{}; + for(int i = 0; i < 16; i++) + res[i] = (Init >> i) & 1; + return res; + } + + std::array value = init(); + + //! The channels that are on, as a bit mask. + constexpr uint16_t mask() const noexcept + { + uint16_t res = 0; + for(int i = 0; i < 16; i++) + if(value[i]) + res |= uint16_t(1u << i); + return res; + } +}; +} diff --git a/include/halp/custom_widgets.hpp b/include/halp/custom_widgets.hpp index af77e0fca..8a27ab5a3 100644 --- a/include/halp/custom_widgets.hpp +++ b/include/halp/custom_widgets.hpp @@ -1,7 +1,10 @@ #pragma once #include +#include #include +#include +#include HALP_MODULE_EXPORT namespace halp { @@ -24,4 +27,64 @@ struct multi_transaction std::function rollback; }; +/** + * What a custom item hands the host when a drag starts from it, from + * + * halp::drag_payload drag_payload(double x, double y); + * + * which the host calls on the UI thread once the mouse has moved far enough + * with a button held. Empty: no drag. Files are absolute paths; the host + * offers them as file URLs, which every drop target that takes files reads. + */ +struct drag_payload +{ + std::vector files; + //! Optional data of a type of its own, e.g. "audio/midi". + std::string mime; + std::string data; + + bool empty() const noexcept { return files.empty() && data.empty(); } +}; + +/** + * How the host plays a clip's audio files at a tempo other than the one they + * were made at. + */ +enum class clip_stretch : uint8_t +{ + //! The host decides, e.g. from what it knows of the file. + automatic, + //! Not at all: the file plays at the speed it was recorded at. + none, + //! Resampled: the pitch follows the tempo. + repitch, + //! Resampled with a cheaper, lower quality resampler. + repitch_fast, + //! Time-stretched, the pitch kept: suited to melodic material. + timestretch, + //! Time-stretched, the pitch kept, attacks preserved: suited to drums. + timestretch_percussive +}; + +/** + * Files a custom item asks the host to place in the timeline next to the + * process, through a member + * + * std::function send_to_timeline; + * + * which the host sets when it can place clips, and leaves empty otherwise. + * The files go together in one new box, each made the way a drop of it + * would, in a slot of its own. `stretch` says how audio files follow the + * tempo; files that are not audio ignore it. + */ +struct timeline_clip +{ + std::vector files; + //! Where the clip starts, in seconds from the start of the process. + double date{}; + //! Name of the box; the first file's name when empty. + std::string name; + //! How the audio files follow the tempo; automatic lets the host choose. + clip_stretch stretch{clip_stretch::automatic}; +}; } diff --git a/include/halp/grid.hpp b/include/halp/grid.hpp new file mode 100644 index 000000000..51c2bdf6a --- /dev/null +++ b/include/halp/grid.hpp @@ -0,0 +1,143 @@ +#pragma once + +/* SPDX-License-Identifier: GPL-3.0-or-later */ + +#include +#include +#include + +#include +#include + +namespace halp +{ +//! An object's own period (e.g. its loop), for grid_unit::period, and the +//! origin of a free grid. +struct grid_period +{ + //! In frames. 0 or less: the object has no period. + int64_t length{}; + //! Frames elapsed since the period's origin, at the first frame of the + //! buffer. It may go past `length`: counted over every restart, multiples + //! above 1 ("2x loop") fall on the same restarts from one buffer to the next; + //! the phase within the period is enough for the others. + int64_t position{}; +}; + +namespace grid_detail +{ +//! First point of a grid of `step` frames, `elapsed` frames past its origin +//! at the buffer start, that falls in [0; frames[. +inline std::optional +next_frame_point(double step, double elapsed, int frames) noexcept +{ + if(!(step > 0.) || frames <= 0) + return std::nullopt; + constexpr double eps = 1e-9; + const double k = std::ceil(elapsed / step - eps); + const double rel = k * step - elapsed; + if(rel >= double(frames)) + return std::nullopt; + const double f = std::floor(rel + eps); + return int(f < 0. ? 0. : f >= frames ? frames - 1 : f); +} +} + +//! Where the next point of a grid falls in the buffer `tk` describes: the +//! frame offset of the first point in [0; tk.frames[, or nullopt when none +//! falls in this buffer. A grid that is none() is no quantization at all: 0, +//! now. +//! +//! - note: 4 x value quarters. Shorter than a bar it restarts at every bar line +//! (a dotted quarter in 4/4 falls on 0, 1.5, 3, then the next bar); a bar or +//! longer it counts from the last signature change. +//! - bars: value bars of the current signature, counted from the last +//! signature change. +//! - free: value seconds, counted from the period's origin if the object has +//! one, from the start of playback otherwise. +//! - period: value times the period, counted from its origin; nullopt +//! without a period. +//! - parent: not resolved here; nullopt. +//! +//! The musical grids do not move while the transport is stopped or rewinding: +//! nullopt then. `Tick` is a halp::tick_musical or anything with its members. +//! No allocation, no lock: callable from the audio thread. +template +std::optional next_grid_point( + const grid_value& g, const Tick& tk, double sample_rate, + const grid_period& period = {}) noexcept +{ + if(g.none()) + return 0; + if(!(g.value > 0.) || tk.frames <= 0) + return std::nullopt; + + switch(g.unit) + { + case grid_unit::free: { + if(!(sample_rate > 0.)) + return std::nullopt; + const double elapsed + = period.length > 0 ? double(period.position) : double(tk.position_in_frames); + return grid_detail::next_frame_point(g.value * sample_rate, elapsed, tk.frames); + } + + case grid_unit::period: { + if(period.length <= 0) + return std::nullopt; + return grid_detail::next_frame_point( + g.value * double(period.length), double(period.position), tk.frames); + } + + case grid_unit::note: + case grid_unit::bars: { + const double lo = tk.start_position_in_quarters; + const double hi = tk.end_position_in_quarters; + if(!(hi > lo)) + return std::nullopt; + + const bool valid_sig = tk.signature.num > 0 && tk.signature.denom > 0; + const double quarters_in_bar + = valid_sig ? 4. * tk.signature.num / tk.signature.denom : 4.; + const double unit + = g.unit == grid_unit::note ? 4. * g.value : g.value * quarters_in_bar; + if(!(unit > 0.)) + return std::nullopt; + + constexpr double eps = 1e-9; + const auto frame_of = [&](double p) { + const int64_t f = musical_to_frame(p, lo, hi, tk.frames); + return int(f < 0 ? 0 : f >= tk.frames ? tk.frames - 1 : f); + }; + + if(g.unit == grid_unit::bars || unit >= quarters_in_bar - eps) + { + const double origin = tk.last_signature_change; + const double k = std::ceil((lo - origin) / unit - eps); + const double p = origin + k * unit; + if(p >= hi) + return std::nullopt; + return frame_of(p); + } + + // Shorter than a bar: from the bar line each point falls after + double best = hi; + tk.for_each_bar_segment(lo, hi, [&](double bar_line, double next_bar) { + double k = std::ceil((lo - bar_line) / unit - eps); + if(k < 0.) + k = 0.; + const double p = bar_line + k * unit; + if(p < next_bar - eps && p < best) + best = p; + }); + if(best >= hi) + return std::nullopt; + return frame_of(best); + } + + case grid_unit::parent: + break; + } + return std::nullopt; +} +} diff --git a/include/halp/grid_chooser.hpp b/include/halp/grid_chooser.hpp new file mode 100644 index 000000000..8566a1b49 --- /dev/null +++ b/include/halp/grid_chooser.hpp @@ -0,0 +1,145 @@ +#pragma once + +/* SPDX-License-Identifier: GPL-3.0-or-later */ + +#include +#include + +#include +#include + +namespace halp +{ +//! What a grid value counts in. The numbers are part of the stored value (the +//! second component of the host's {value, unit} pair) and must not change. +enum class grid_unit : int8_t +{ + //! The grid of the parent (reserved, for scenarios). + parent = -1, + //! Seconds. 0 is no grid at all ("None"). + free = 0, + //! A fraction of a whole note: 1 is a whole note (four quarters, whatever + //! the signature), 0.25 a quarter, 1/12 an eighth triplet, 3/16 a dotted + //! eighth, 2 two whole notes. Never a bar. + note = 1, + //! Whole bars of the current signature. + bars = 2, + //! A multiple of the object's own period, e.g. its loop: 1 is every + //! restart, 2 every other one, 1/2 twice per period. + period = 3, +}; + +//! A grid, as the object receives it: the value is not converted to seconds, +//! see halp/grid.hpp to find where its points fall. +struct grid_value +{ + grid_unit unit{grid_unit::free}; + double value{}; + //! Reserved: a shift of the grid, in the same unit. Nothing reads it yet. + double offset{}; + + //! No quantization. + constexpr bool none() const noexcept + { + return unit == grid_unit::free && !(value > 0.); + } + + static constexpr grid_value seconds(double s) noexcept + { + return {grid_unit::free, s, 0.}; + } + static constexpr grid_value note(double whole_note_fraction) noexcept + { + return {grid_unit::note, whole_note_fraction, 0.}; + } + static constexpr grid_value bars(double count) noexcept + { + return {grid_unit::bars, count, 0.}; + } + static constexpr grid_value period(double multiple) noexcept + { + return {grid_unit::period, multiple, 0.}; + } + + //! What a halp::time_chooser held: a fraction of a whole note when synced, + //! seconds otherwise. A migration whose synced values meant bars rather + //! than whole notes uses bars() for those. + static constexpr grid_value from_time_chooser(double value, bool sync) noexcept + { + return sync ? note(value) : seconds(value); + } + + constexpr bool operator==(const grid_value&) const noexcept = default; +}; + +//! The units a grid chooser offers, or-ed together. +namespace grid_units +{ +inline constexpr uint32_t free = 1u << 0; +inline constexpr uint32_t note = 1u << 1; +inline constexpr uint32_t bars = 1u << 2; +inline constexpr uint32_t period = 1u << 3; +inline constexpr uint32_t parent = 1u << 4; +inline constexpr uint32_t musical = free | note | bars; +} + +constexpr uint32_t grid_unit_flag(grid_unit u) noexcept +{ + switch(u) + { + case grid_unit::free: + return grid_units::free; + case grid_unit::note: + return grid_units::note; + case grid_unit::bars: + return grid_units::bars; + case grid_unit::period: + return grid_units::period; + case grid_unit::parent: + return grid_units::parent; + } + return 0; +} + +struct grid_setup +{ + //! Which units the host lets the user pick, see grid_units. + uint32_t units = grid_units::musical; + grid_value init = grid_value::note(0.25); + //! The longest grid in bars. + int max_bars = 16; + //! The longest free time, in seconds. + double max_seconds = 8.; +}; + +/// Grid chooser /// +//! A quantization grid: free seconds, a note value, a number of bars or a +//! multiple of the object's own period, as allowed by `setup.units`. +//! `period` names the period in the host's labels ("loop": "2x loop"). +template < + static_string lit, grid_setup setup = grid_setup{}, static_string period = "loop"> +struct grid_chooser +{ + enum class widget + { + grid_chooser + }; + + static clang_buggy_consteval auto name() { return std::string_view{lit.value}; } + static constexpr grid_setup grid() noexcept { return setup; } + static constexpr std::string_view period_name() noexcept + { + return std::string_view{period.value}; + } + + grid_value value = setup.init; + + operator grid_value&() noexcept { return value; } + operator const grid_value&() const noexcept { return value; } + auto& operator=(grid_value t) noexcept + { + value = t; + return *this; + } +}; +} diff --git a/include/halp/layout.hpp b/include/halp/layout.hpp index 3acf224f0..d6ba0fb74 100644 --- a/include/halp/layout.hpp +++ b/include/halp/layout.hpp @@ -63,6 +63,16 @@ enum class colors editable_value_dark, }; +// Where a container places its children across the free space of their cell, +// row or column; it does not reach the containers nested in it. +// halp_meta(alignment, halp::alignments::left) +enum class alignments +{ + left, + center, + right +}; + struct spacing { halp_meta(layout, layouts::spacing) diff --git a/include/halp/note_matrix.hpp b/include/halp/note_matrix.hpp new file mode 100644 index 000000000..0b2f9969e --- /dev/null +++ b/include/halp/note_matrix.hpp @@ -0,0 +1,25 @@ +#pragma once +#include +#include + +#include +#include + +namespace halp +{ +// Where each of the 12 degrees of an octave goes: value[d] is the degree it +// plays, 0 to 12 (12 being the octave above), or off to mute it. Hosts draw it +// as a matrix of input degrees (columns) by output degrees (rows). +template +struct note_matrix +{ + static constexpr int off = -1; + static clang_buggy_consteval auto name() { return std::string_view{lit.value}; } + enum class widget + { + note_matrix + }; + + std::array value{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11}; +}; +} diff --git a/include/halp/toggle_grid.hpp b/include/halp/toggle_grid.hpp new file mode 100644 index 000000000..20d13ea15 --- /dev/null +++ b/include/halp/toggle_grid.hpp @@ -0,0 +1,49 @@ +#pragma once +#include +#include + +#include +#include +#include +#include + +namespace halp +{ +// A grid of named toggles, one per choice of a set: value[i] is whether the +// choice labels()[i] is on. A field derived from it names the cells with +// static constexpr std::array labels(); +// and may set how many cells make a row with +// static constexpr int items_per_line(); +// Init is a bit mask, bit i for cell i. Hosts draw a toggle per cell. +template +struct toggle_grid +{ + static_assert(N > 0 && N <= 64); + static clang_buggy_consteval auto name() { return std::string_view{lit.value}; } + enum class widget + { + toggle_grid + }; + static constexpr std::size_t count = N; + + static constexpr std::array init() noexcept + { + std::array res{}; + for(std::size_t i = 0; i < N; i++) + res[i] = (Init >> i) & 1; + return res; + } + + std::array value = init(); + + //! The cells that are on, as a bit mask. + constexpr uint64_t mask() const noexcept + { + uint64_t res = 0; + for(std::size_t i = 0; i < N; i++) + if(value[i]) + res |= uint64_t(1) << i; + return res; + } +}; +} diff --git a/include/halp/value_list.hpp b/include/halp/value_list.hpp new file mode 100644 index 000000000..477364e6a --- /dev/null +++ b/include/halp/value_list.hpp @@ -0,0 +1,45 @@ +#pragma once +#include +#include + +#include + +#include +#include +#include + +namespace halp +{ +//! One component of a list element: a number in [min, max], or, when choices +//! is not empty, the index of one of them. +struct list_column +{ + std::string_view name{}; + double min{0.}; + double max{127.}; + double init{0.}; + bool integral{true}; + std::span choices{}; +}; + +// A list of up to Capacity elements, edited row by row: hosts append, remove, +// duplicate and move rows, or take the whole list as text. Element is a number +// or an aggregate of numbers, one column per field, described by an optional +// static constexpr std::array columns(); +// and the count may be bounded with static constexpr int min_count() / +// max_count(). Init is the starting list. +// The value has a fixed capacity: the audio thread never allocates for it. +template +struct value_list +{ + static clang_buggy_consteval auto name() { return std::string_view{lit.value}; } + enum class widget + { + value_list + }; + using element_type = Element; + static constexpr std::size_t capacity = Capacity; + + boost::container::static_vector value{Init...}; +}; +} diff --git a/tests/test_ossia_midi.cpp b/tests/test_ossia_midi.cpp index 31f276adf..163e7b37a 100644 --- a/tests/test_ossia_midi.cpp +++ b/tests/test_ossia_midi.cpp @@ -13,8 +13,12 @@ struct value_metadata_on { static constexpr bool local_midi_tick_batch = true; }; +struct value_metadata_absent +{ +}; static_assert(!oscr::use_local_midi_tick_batch); static_assert(oscr::use_local_midi_tick_batch); +static_assert(oscr::use_local_midi_tick_batch); template struct raw_midi_port @@ -264,3 +268,182 @@ TEST_CASE("ossia MIDI publishes bank and RPN controllers without deferred conver REQUIRE(libremidi::midi1_from_ump(output.front()).bytes == message.bytes); } } + +namespace +{ +using sysex_node = oscr::safe_node>; + +const std::vector mtc_full_frame{0xF0, 0x7F, 0x7F, 0x01, 0x01, + 0x21, 0x02, 0x03, 0x04, 0xF7}; + +std::vector sysex_of(std::size_t data_bytes) +{ + std::vector bytes{0xF0}; + for(std::size_t i = 0; i < data_bytes; i++) + bytes.push_back(uint8_t(i % 0x80)); + bytes.push_back(0xF7); + return bytes; +} + +std::vector packets_of(const std::vector& bytes, int64_t ts) +{ + libremidi::midi1_to_midi2 conv; + std::vector packets; + conv.convert_packets( + bytes.data(), bytes.size(), ts, + [&](const libremidi::ump& p) { packets.push_back(p); }); + return packets; +} + +std::vector bytes_of(const auto& message) +{ + return {std::begin(message.bytes), std::end(message.bytes)}; +} + +struct sysex_rig +{ + ossia::execution_state state; + sysex_node node{64, 44100., 1}; + ossia::midi_inlet input; + + sysex_rig() { initialize(node, state); } + auto& effect() { return node.impl.effect; } + + //! What the MIDI inputs see for one run over [start, start + frames). + void run(int start, int frames) + { + auto& fx = effect(); + oscr::process_before_run> before{ + node, fx, start, frames}; + before(fx.inputs.raw, input, avnd::field_index<0>{}); + before(fx.inputs.midi1, input, avnd::field_index<1>{}); + before(fx.inputs.ump, input, avnd::field_index<2>{}); + before(fx.inputs.generic, input, avnd::field_index<3>{}); + } + + void add(const std::vector& packets) + { + for(auto& p : packets) + input.data.messages.push_back(p); + } + + //! One message on each MIDI 1 input, @p bytes at @p ts. + void require_midi1(const std::vector& bytes, int64_t ts) + { + auto& in = effect().inputs; + REQUIRE(in.raw.size == 1); + CHECK(bytes_of(in.raw.midi_messages[0]) == bytes); + CHECK(in.raw.midi_messages[0].timestamp == ts); + REQUIRE(in.midi1.size() == 1); + CHECK(bytes_of(in.midi1.midi_messages[0]) == bytes); + CHECK(in.midi1.midi_messages[0].timestamp == ts); + REQUIRE(in.generic.size() == 1); + CHECK(bytes_of(in.generic.midi_messages[0]) == bytes); + CHECK(in.generic.midi_messages[0].timestamp == ts); + } + + void require_no_midi1() + { + auto& in = effect().inputs; + CHECK(in.raw.size == 0); + CHECK(in.midi1.size() == 0); + CHECK(in.generic.size() == 0); + } +}; +} + +TEST_CASE("ossia MIDI 1 inputs get an MTC full frame as one message, UMP inputs its packets") +{ + sysex_rig rig; + const auto packets = packets_of(mtc_full_frame, 5); + REQUIRE(packets.size() == 2); + rig.add(packets); + rig.run(4, 4); + + rig.require_midi1(mtc_full_frame, 1); + auto& ump = rig.effect().inputs.ump; + REQUIRE(ump.size() == 2); + for(int i = 0; i < 2; i++) + { + CHECK(std::equal( + std::begin(ump.midi_messages[i].data), std::end(ump.midi_messages[i].data), + std::begin(packets[i].data))); + CHECK(ump.midi_messages[i].timestamp == 1); + } +} + +TEST_CASE("ossia MIDI 1 inputs join system-exclusive split over the runs of a buffer") +{ + sysex_rig rig; + auto packets = packets_of(mtc_full_frame, 0); + packets[0].timestamp = 2; + packets[1].timestamp = 6; + rig.add(packets); + + rig.run(0, 4); + rig.require_no_midi1(); + CHECK(rig.effect().inputs.ump.size() == 1); + + rig.run(4, 4); + rig.require_midi1(mtc_full_frame, 2); + CHECK(rig.effect().inputs.ump.size() == 1); +} + +TEST_CASE("ossia MIDI 1 inputs join system-exclusive split over buffers") +{ + sysex_rig rig; + const auto bytes = sysex_of(40); + auto packets = packets_of(bytes, 0); + REQUIRE(packets.size() == 7); + + for(int i = 0; i < 3; i++) + packets[i].timestamp = 7; + rig.add({packets.begin(), packets.begin() + 3}); + rig.run(0, 8); + rig.require_no_midi1(); + + rig.input.data.messages.clear(); + rig.add({packets.begin() + 3, packets.end()}); + rig.run(0, 8); + rig.require_midi1(bytes, 0); +} + +TEST_CASE("ossia MIDI 1 inputs get long system-exclusive whole, a too long one not at all") +{ + sysex_rig rig; + for(std::size_t n : {13, 300, 4094}) + { + INFO(n << " data bytes"); + const auto bytes = sysex_of(n); + rig.input.data.messages.clear(); + rig.add(packets_of(bytes, 0)); + rig.run(0, 8); + rig.require_midi1(bytes, 0); + } + + rig.input.data.messages.clear(); + rig.add(packets_of(sysex_of(oscr::midi_input_sysex_capacity - 1), 0)); + rig.run(0, 8); + rig.require_no_midi1(); + + rig.input.data.messages.clear(); + rig.add(packets_of(mtc_full_frame, 0)); + rig.run(0, 8); + rig.require_midi1(mtc_full_frame, 0); +} + +TEST_CASE("ossia MIDI 1 inputs drop a system-exclusive message cut short by the next one") +{ + sysex_rig rig; + auto cut = packets_of(sysex_of(20), 0); + cut.pop_back(); + rig.add(cut); + rig.run(0, 8); + rig.require_no_midi1(); + + rig.input.data.messages.clear(); + rig.add(packets_of(mtc_full_frame, 3)); + rig.run(0, 8); + rig.require_midi1(mtc_full_frame, 3); + CHECK(rig.node.midi_input_assembly.input(avnd::field_index<0>{}).dropped() == 1); +}