From c239ed4cec9eec8eecbf0b31bbc50db9f3888b88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Thu, 22 Oct 2026 07:58:27 -0400 Subject: [PATCH 01/36] midi: shared helpers for the MIDI objects Message kinds and range lists, a note tracker that pairs every note-on with one note-off, a frame-dated event queue, a per-note channel allocator, euclid and swing, and a chord-type / mode table (chord types from free-midi-chords, MIT). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qr9WkcUjEGCueg7eG1KxSr --- .../MidiCommon/ChannelAllocator.hpp | 153 ++++++++ .../MidiScaler/MidiCommon/EventQueue.hpp | 149 ++++++++ .../MidiScaler/MidiCommon/Messages.hpp | 341 ++++++++++++++++++ .../MidiScaler/MidiCommon/NoteTracker.hpp | 139 +++++++ .../Advanced/MidiScaler/MidiCommon/Rhythm.hpp | 34 ++ .../Advanced/MidiScaler/MidiCommon/Theory.hpp | 152 ++++++++ 6 files changed, 968 insertions(+) create mode 100644 examples/Advanced/MidiScaler/MidiCommon/ChannelAllocator.hpp create mode 100644 examples/Advanced/MidiScaler/MidiCommon/EventQueue.hpp create mode 100644 examples/Advanced/MidiScaler/MidiCommon/Messages.hpp create mode 100644 examples/Advanced/MidiScaler/MidiCommon/NoteTracker.hpp create mode 100644 examples/Advanced/MidiScaler/MidiCommon/Rhythm.hpp create mode 100644 examples/Advanced/MidiScaler/MidiCommon/Theory.hpp diff --git a/examples/Advanced/MidiScaler/MidiCommon/ChannelAllocator.hpp b/examples/Advanced/MidiScaler/MidiCommon/ChannelAllocator.hpp new file mode 100644 index 00000000..4b4e07da --- /dev/null +++ b/examples/Advanced/MidiScaler/MidiCommon/ChannelAllocator.hpp @@ -0,0 +1,153 @@ +#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; } + + 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 00000000..45e1561b --- /dev/null +++ b/examples/Advanced/MidiScaler/MidiCommon/EventQueue.hpp @@ -0,0 +1,149 @@ +#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; + }; + + void schedule(int64_t at, const libremidi::message& m) noexcept + { + if(m_size == Capacity) + { + if(!is_note_off(m)) + return; + 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_size++; + } + + //! 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. + 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); + 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; } + 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 00000000..854aab2b --- /dev/null +++ b/examples/Advanced/MidiScaler/MidiCommon/Messages.hpp @@ -0,0 +1,341 @@ +#pragma once + +/* SPDX-License-Identifier: GPL-3.0-or-later */ + +#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 < 0xF0) + { + switch(s & 0xF0) + { + case 0x80: + return midi_kind::note_off; + case 0x90: + return (m.size() >= 3 && 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)); +} + +//! 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 00000000..92325f68 --- /dev/null +++ b/examples/Advanced/MidiScaler/MidiCommon/NoteTracker.hpp @@ -0,0 +1,139 @@ +#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; + } + + 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; + } + + 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/Rhythm.hpp b/examples/Advanced/MidiScaler/MidiCommon/Rhythm.hpp new file mode 100644 index 00000000..32019f8a --- /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 00000000..c604f08d --- /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; +} +} From a701489c63949266aabfa60a2201a22949465012 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Thu, 22 Oct 2026 08:16:56 -0400 Subject: [PATCH 02/36] midi: tagged events in the event queue, note_tracker::find Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qr9WkcUjEGCueg7eG1KxSr --- .../Advanced/MidiScaler/MidiCommon/EventQueue.hpp | 11 +++++++++-- .../Advanced/MidiScaler/MidiCommon/NoteTracker.hpp | 9 +++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/examples/Advanced/MidiScaler/MidiCommon/EventQueue.hpp b/examples/Advanced/MidiScaler/MidiCommon/EventQueue.hpp index 45e1561b..f7dc995e 100644 --- a/examples/Advanced/MidiScaler/MidiCommon/EventQueue.hpp +++ b/examples/Advanced/MidiScaler/MidiCommon/EventQueue.hpp @@ -39,9 +39,11 @@ struct event_queue { int64_t at{}; libremidi::message msg; + //! Free for the owner, e.g. to know which input note an event belongs to. + uint32_t tag{}; }; - void schedule(int64_t at, const libremidi::message& m) noexcept + void schedule(int64_t at, const libremidi::message& m, uint32_t tag = 0) noexcept { if(m_size == Capacity) { @@ -69,11 +71,13 @@ struct event_queue m_events[pos].at = at; m_events[pos].msg = m; m_events[pos].msg.timestamp = 0; + m_events[pos].tag = tag; m_size++; } //! 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 { @@ -85,7 +89,10 @@ struct event_queue { auto msg = m_events[n].msg; msg.timestamp = std::clamp(m_events[n].at - now, 0, frames - 1); - send(std::move(msg)); + 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) diff --git a/examples/Advanced/MidiScaler/MidiCommon/NoteTracker.hpp b/examples/Advanced/MidiScaler/MidiCommon/NoteTracker.hpp index 92325f68..5fef9e7f 100644 --- a/examples/Advanced/MidiScaler/MidiCommon/NoteTracker.hpp +++ b/examples/Advanced/MidiScaler/MidiCommon/NoteTracker.hpp @@ -102,6 +102,15 @@ struct note_tracker 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]; } From 42484ac521eabc5b7b29fac60bd2d16488c0dd85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Thu, 22 Oct 2026 08:16:56 -0400 Subject: [PATCH 03/36] midi: retuned MIDI output in Midi scaler, Midi dispatcher, Midi MPE Midi scaler plays its Scala tuning as MIDI: one channel per note with a pitch bend for the configured range, or MTS single-note tuning sysex. Midi dispatcher spreads notes over a channel set (round robin, random, ping-pong, chord orderings with a grace period, surplus channel). Midi MPE converts MIDI to MPE and back, and shapes or randomizes per-note expression. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qr9WkcUjEGCueg7eG1KxSr --- .../Advanced/MidiScaler/MidiDispatcher.hpp | 446 +++++++++ examples/Advanced/MidiScaler/MidiMpe.hpp | 870 ++++++++++++++++++ examples/Advanced/MidiScaler/MidiScaler.hpp | 397 +++++++- 3 files changed, 1712 insertions(+), 1 deletion(-) create mode 100644 examples/Advanced/MidiScaler/MidiDispatcher.hpp create mode 100644 examples/Advanced/MidiScaler/MidiMpe.hpp diff --git a/examples/Advanced/MidiScaler/MidiDispatcher.hpp b/examples/Advanced/MidiScaler/MidiDispatcher.hpp new file mode 100644 index 00000000..a3272b60 --- /dev/null +++ b/examples/Advanced/MidiScaler/MidiDispatcher.hpp @@ -0,0 +1,446 @@ +#pragma once + +/* SPDX-License-Identifier: GPL-3.0-or-later */ + +#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") + 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") + + 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::lineedit<"Channels", "1-4"> + { + halp_meta(description, "Channels the notes are spread over, for example 1-4, 6") + } channels; + + struct : halp::spinbox_i32<"Surplus", halp::range{0, 16, 0}> + { + halp_meta( + description, + "Channel for the notes that find no free channel (0: they are dropped)") + } surplus; + + struct : halp::hslider_f32<"Grace", 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", 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 + { + 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 + { + const auto& list = m_channel_list.update(inputs.channels.value, 1, 16); + uint16_t mask = 0; + for(int ch = 1; ch <= 16; ch++) + if(list.contains(ch)) + mask |= uint16_t(1u << (ch - 1)); + m_alloc.set_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)) + { + auto msg = m; + msg.timestamp = ts; + emit(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; + } + } + } + + void start_note(int in_ch, int pitch, int vel, std::optional ch, int64_t ts) noexcept + { + auto emit = emitter(); + // A dropped note 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); + } + } + + note_tracker<128, 1> m_notes; + channel_allocator m_alloc; + cached_range_list m_channel_list; + //! 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/MidiMpe.hpp b/examples/Advanced/MidiScaler/MidiMpe.hpp new file mode 100644 index 00000000..0a07c664 --- /dev/null +++ b/examples/Advanced/MidiScaler/MidiMpe.hpp @@ -0,0 +1,870 @@ +#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") + 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") + + 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", 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::hslider_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::hslider_f32<"Random pressure", halp::range{0., 1., 0.}> + { + halp_meta(description, "Per-note random pressure added from note start") + } random_pressure; + + struct : halp::hslider_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 + { + 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)); + }; + } + + 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; + } + + 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); + if(mpe_range != m_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)) + { + emit(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)) + emit(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(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; + } + } + + //! 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}; + 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/MidiScaler.hpp b/examples/Advanced/MidiScaler/MidiScaler.hpp index 3e378a8b..832bb652 100644 --- a/examples/Advanced/MidiScaler/MidiScaler.hpp +++ b/examples/Advanced/MidiScaler/MidiScaler.hpp @@ -6,6 +6,10 @@ #include "Tunings.h" #include "TuningsImpl.h" +#include +#include +#include + #include #include #include @@ -17,6 +21,7 @@ #include +#include #include #include #include @@ -39,7 +44,38 @@ struct MidiScaler 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_meta(description, "Convert active MIDI notes to frequencies using Scala tuning and keyboard-mapping files, and play them retuned on a MIDI output.") + + 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 whenever it changes.") + 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 { @@ -108,6 +144,32 @@ 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::lineedit<"Channels", "1-16"> + { + halp_meta( + description, + "Channels the notes are spread over, for example 2-16. " + "Empty or invalid: 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; } inputs; struct @@ -117,6 +179,8 @@ struct MidiScaler halp_meta(name, "Frequency output"); std::vector value{}; } freq; + + halp::midi_bus<"MIDI", libremidi::message> midi; } outputs; void update_tuning() @@ -132,9 +196,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 { @@ -160,8 +226,12 @@ struct MidiScaler void prepare(halp::setup s) { update_tuning(); } + void stop() noexcept { m_stop = true; } + void operator()(int N) { + process_midi_output(N); + outputs.freq.value.clear(); // Update the active notes @@ -192,5 +262,330 @@ 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. + //! Four keys per message keep each message within libremidi's inline storage. + static constexpr int mts_keys_per_message = 4; + + 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 auto& list = m_channel_list.update(inputs.channels.value, 1, 16); + uint16_t mask = 0; + for(int ch = 1; ch <= 16; ch++) + if(list.contains(ch)) + mask |= uint16_t(1u << (ch - 1)); + 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)) + { + send_mts(0, emit); + m_mts_sent = true; + } + + 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. * m_range; + 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; + cached_range_list m_channel_list; + + //! 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_stop{false}; }; } From a53606589bb778f8aa230299939d3a5a8e3b8574 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Thu, 22 Oct 2026 08:16:56 -0400 Subject: [PATCH 04/36] midi: Midi sustain Holds note-offs while a pedal (CC 64 or a toggle) is down, in All, Chord, Semi-sostenuto and Sostenuto modes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qr9WkcUjEGCueg7eG1KxSr --- examples/Advanced/MidiScaler/MidiSustain.hpp | 280 +++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 examples/Advanced/MidiScaler/MidiSustain.hpp diff --git a/examples/Advanced/MidiScaler/MidiSustain.hpp b/examples/Advanced/MidiScaler/MidiSustain.hpp new file mode 100644 index 00000000..7f3412c5 --- /dev/null +++ b/examples/Advanced/MidiScaler/MidiSustain.hpp @@ -0,0 +1,280 @@ +#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") + 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") + + 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::toggle<"Pedal"> + { + halp_meta(description, "Holds the pedal down on every channel, 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. + for(int k = 0; k < 128; k++) + if(m_notes[c][k].sustained && !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{}; +}; +} From e3951d2646be4e8edd66c7d8f0317f7befe9d7f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Thu, 22 Oct 2026 09:28:57 -0400 Subject: [PATCH 05/36] midi: Midi filter selects every kind, ranges and remaps, Rejected output Kinds list (including sysex and real-time), channel / note / CC range lists with an invert, a value range, linear channel / note / CC maps, and a Rejected output with everything that did not pass. Note-offs follow their note-on's output and mapping when the settings change while notes are held. The Channel control keeps the selected channel, as its label says; it used to drop it. Each CC is sent once in CC mode; it used to be sent twice. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qr9WkcUjEGCueg7eG1KxSr --- examples/Advanced/MidiScaler/MidiFilter.hpp | 855 ++++++++++++++++---- 1 file changed, 706 insertions(+), 149 deletions(-) diff --git a/examples/Advanced/MidiScaler/MidiFilter.hpp b/examples/Advanced/MidiScaler/MidiFilter.hpp index b8248785..34db1fcd 100644 --- a/examples/Advanced/MidiScaler/MidiFilter.hpp +++ b/examples/Advanced/MidiScaler/MidiFilter.hpp @@ -2,6 +2,8 @@ /* SPDX-License-Identifier: GPL-3.0-or-later */ +#include + #include #include #include @@ -16,10 +18,216 @@ #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". + * + * 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; + } + } +}; + +//! Keeps a kind_set in sync with a text control without allocating. +struct cached_kind_set +{ + static constexpr std::size_t max_text = 256; + kind_set set; + + const kind_set& update(std::string_view text) 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 set; + std::copy(text.begin(), text.end(), m_text.begin()); + m_size = text.size(); + m_parsed = true; + set.parse(text); + return set; + } + +private: + std::array m_text{}; + std::size_t m_size{}; + bool m_parsed{}; +}; + +/** + * @brief A linear map between two integer ranges, written "1-8 > 9-16". + * + * Empty or unparseable text gives an inactive map. + */ +struct cached_range_map +{ + cached_range_list in, out; + bool active{}; + + void update(std::string_view text, int min, int max, bool notes = false) noexcept + { + const auto sep = text.find('>'); + if(sep == std::string_view::npos) + { + active = false; + return; + } + const auto& a = in.update(text.substr(0, sep), min, max, notes); + const auto& b = out.update(text.substr(sep + 1), min, max, notes); + active = in.valid && out.valid && a.count == 1 && b.count == 1; + } + + bool contains(int v) const noexcept + { + return active && in.list.contains(v); + } + + //! Rounds to the nearest output value. + int map(int v) const noexcept + { + const auto [lo, hi] = in.list.ranges[0]; + const auto [olo, ohi] = out.list.ranges[0]; + if(hi == lo) + return olo; + const int num = 2 * (v - lo) * (ohi - olo) + (hi - lo); + return olo + num / (2 * (hi - lo)); + } +}; + +/** + * @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. + * + * Text lists ("Channels", "Notes", "CCs", "Values") are comma-separated + * inclusive ranges such as "1-4, 6"; notes accept names such as "C-1-B2, C4". + * An empty list 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 { @@ -28,7 +236,11 @@ struct 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_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 +259,16 @@ struct MidiFilter NoteAny, NoteRunning }; + enum ValueTest + { + Inside, + Outside + }; + enum OutOfMap + { + Pass, + Drop + }; struct note_int { @@ -62,7 +284,12 @@ struct MidiFilter struct { halp::midi_bus<"MIDI messages", libremidi::message> midi; - halp::enum_t filter; + struct : halp::enum_t + { + halp_meta( + description, + "Kind of message kept when Kinds is empty, 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 +301,60 @@ 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; + + struct : halp::lineedit<"Kinds", ""> + { + halp_meta( + description, + "Kinds kept, e.g. note, cc, program, bend, pressure, poly, sysex, mtc, " + "spp, song, tune, clock, start, continue, stop, sensing, reset, or the " + "groups all, channel, system, realtime, transport. A leading '-' " + "removes. Empty: Filter type decides.") + } kinds; + struct : halp::lineedit<"Channels", ""> + { + halp_meta(description, "Channels kept, e.g. 1-4, 10. Empty: all.") + } channels; + struct : halp::lineedit<"Notes", ""> + { + halp_meta( + description, "Notes kept, by number or name, e.g. C-1-B2, 60. Empty: all.") + } notes; + struct : halp::lineedit<"CCs", ""> + { + halp_meta(description, "Controller numbers kept, e.g. 1, 7, 64-69. Empty: all.") + } ccs; + struct : halp::toggle<"Drop in range"> + { + halp_meta(description, "Drop what Channels, Notes and CCs match instead of keeping it") + } drop_in_range; + struct : halp::lineedit<"Values", ""> + { + 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::lineedit<"Channel map", ""> + { + halp_meta(description, "Linear channel remap, e.g. 1-8 > 9-16. Empty: none.") + } channel_map; + struct : halp::lineedit<"Note map", ""> + { + halp_meta(description, "Linear note remap, e.g. C3-C4 > C4-C5. Empty: none.") + } note_map; + struct : halp::lineedit<"CC map", ""> + { + halp_meta(description, "Linear controller number remap, e.g. 1-8 > 21-28. Empty: none.") + } 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,6 +369,11 @@ 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; ossia::small_flat_map running; @@ -95,6 +381,290 @@ struct MidiFilter 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 parsed from the text controls + cached_kind_set m_kinds; + cached_range_list m_channels, m_notes, m_ccs, m_values; + cached_range_map 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.update(inputs.kinds.value); + m_channels.update(inputs.channels.value, 1, 16); + m_notes.update(inputs.notes.value, 0, 127, true); + m_ccs.update(inputs.ccs.value, 0, 127); + m_values.update(inputs.values.value, 0, 127); + m_channel_map.update(inputs.channel_map.value, 1, 16); + m_note_map.update(inputs.note_map.value, 0, 127, true); + m_cc_map.update(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 = e.to_main ? libremidi::channel_events::note_off(e.channel, e.note, 0) + : libremidi::channel_events::note_off(c, n, 0); + off.timestamp = ts; + send(e.to_main, off); + } + slot.count = 0; + } + } + } + + //! Kind selection: the Kinds list, or the legacy Filter type when it is empty. + bool kind_accepts(const libremidi::message& m, midi_kind k) const noexcept + { + if(!m_kinds.set.empty) + return m_kinds.set.contains(k); + + 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; + if(!m_channels.list.empty()) + { + tested = true; + inside = inside && m_channels.list.contains(ch); + } + if(is_note_kind(k) && !m_notes.list.empty() && m.size() >= 2) + { + tested = true; + inside = inside && m_notes.list.contains(m.bytes[1]); + } + if(k == midi_kind::control_change && !m_ccs.list.empty() && m.size() >= 2) + { + tested = true; + inside = inside && m_ccs.list.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(m_channel_map.contains(ch)) + set_channel(m, m_channel_map.map(ch)); + else if(drop) + return false; + } + if(is_note_kind(k) && m_note_map.active && m.size() >= 2) + { + if(m_note_map.contains(m.bytes[1])) + m.bytes[1] = uint8_t(m_note_map.map(m.bytes[1])); + else if(drop) + return false; + } + if(k == midi_kind::control_change && m_cc_map.active && m.size() >= 2) + { + if(m_cc_map.contains(m.bytes[1])) + m.bytes[1] = uint8_t(m_cc_map.map(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 = 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 +726,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); - } - } - 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); - } + case Type::NoteRunning: + if(m.size() < 3) 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; } } }; From 80861bc697efd6a10b07587639248df784848421 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Thu, 22 Oct 2026 09:28:57 -0400 Subject: [PATCH 06/36] midi: Midi transform and Midi CC snapshot Midi transform turns one message kind into another, with byte sources, insert / replace / drop modes and an input filter; produced notes always get their note-off. Midi CC snapshot sends up to eight stored CC values, immediately or with soft takeover. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qr9WkcUjEGCueg7eG1KxSr --- .../Advanced/MidiScaler/MidiCCSnapshot.hpp | 191 ++++++ .../Advanced/MidiScaler/MidiTransform.hpp | 642 ++++++++++++++++++ 2 files changed, 833 insertions(+) create mode 100644 examples/Advanced/MidiScaler/MidiCCSnapshot.hpp create mode 100644 examples/Advanced/MidiScaler/MidiTransform.hpp diff --git a/examples/Advanced/MidiScaler/MidiCCSnapshot.hpp b/examples/Advanced/MidiScaler/MidiCCSnapshot.hpp new file mode 100644 index 00000000..2a663768 --- /dev/null +++ b/examples/Advanced/MidiScaler/MidiCCSnapshot.hpp @@ -0,0 +1,191 @@ +#pragma once + +/* SPDX-License-Identifier: GPL-3.0-or-later */ + +#include + +#include +#include +#include +#include +#include + +#include +#include + +namespace mtk +{ +/** + * @brief Up to 8 stored controller values on one channel, sent on demand. + * + * Incoming MIDI passes through. In Takeover mode, once a slot has been sent, + * incoming values of the same controller on the same channel are held back + * until they reach or cross the stored value, so that moving a physical knob + * does not make the parameter jump away from the recalled value. + */ +struct MidiCCSnapshot +{ + halp_meta(name, "Midi CC snapshot") + halp_meta(c_name, "midi_cc_snapshot") + halp_meta(category, "Midi") + halp_meta(author, "ossia score") + halp_meta(description, "Send up to 8 stored controller values on a channel") + halp_meta(manual_url, "https://ossia.io/score-docs/processes/midi-cc-snapshot.html") + halp_meta(uuid, "e941cff0-8ebb-4259-a146-29ce10125791") + + static constexpr int slots = 8; + + enum Mode + { + Immediate, + Takeover + }; + + template + struct cc_number : halp::spinbox_i32 + { + halp_meta(description, "Controller number of this slot") + }; + template + struct cc_value : halp::hslider_i32 + { + halp_meta(description, "Value sent for this slot") + }; + + struct + { + halp::midi_bus<"MIDI", libremidi::message> midi; + struct : halp::impulse_button<"Send"> + { + halp_meta(description, "Send every active slot") + } send; + struct : halp::toggle<"Send on change"> + { + halp_meta(description, "Also send a slot whenever its number or value changes") + } on_change; + struct : halp::enum_t + { + halp_meta( + description, + "Takeover: after a slot is sent, incoming values of that controller are " + "held back until they reach the stored value") + } mode; + struct : halp::spinbox_i32<"Channel", halp::range{1, 16, 1}> + { + halp_meta(description, "Channel of the controllers sent") + } channel; + struct : halp::spinbox_i32<"Slots", halp::range{1, slots, slots}> + { + halp_meta(description, "Number of active slots") + } count; + + cc_number<"CC 1", 20> cc1; + cc_value<"Value 1"> value1; + cc_number<"CC 2", 21> cc2; + cc_value<"Value 2"> value2; + cc_number<"CC 3", 22> cc3; + cc_value<"Value 3"> value3; + cc_number<"CC 4", 23> cc4; + cc_value<"Value 4"> value4; + cc_number<"CC 5", 24> cc5; + cc_value<"Value 5"> value5; + cc_number<"CC 6", 25> cc6; + cc_value<"Value 6"> value6; + cc_number<"CC 7", 26> cc7; + cc_value<"Value 7"> value7; + cc_number<"CC 8", 27> cc8; + cc_value<"Value 8"> value8; + } inputs; + + struct + { + halp::midi_bus<"MIDI", libremidi::message> midi; + } outputs; + + void stop() noexcept { m_stop = true; } + + using tick = halp::tick_musical; + void operator()(halp::tick_musical) noexcept + { + if(m_stop) + { + m_stop = false; + m_locked = {}; + } + + const std::array ccs{inputs.cc1, inputs.cc2, inputs.cc3, + inputs.cc4, inputs.cc5, inputs.cc6, + inputs.cc7, inputs.cc8}; + const std::array values{inputs.value1, inputs.value2, inputs.value3, + inputs.value4, inputs.value5, inputs.value6, + inputs.value7, inputs.value8}; + const int count = std::clamp(int(inputs.count), 1, slots); + const int channel = std::clamp(int(inputs.channel), 1, 16); + + for(int i = 0; i < slots; i++) + { + const bool changed = m_initialized + && (ccs[i] != m_last_cc[i] || values[i] != m_last_value[i] + || channel != m_last_channel); + if(i < count && (inputs.send || (inputs.on_change && changed))) + send_slot(i, channel, ccs[i], values[i]); + m_last_cc[i] = ccs[i]; + m_last_value[i] = values[i]; + } + m_last_channel = channel; + m_initialized = true; + + for(const auto& m : inputs.midi) + { + if(pass(m, channel, count, ccs, values)) + outputs.midi.push_back(m); + } + } + +private: + std::array m_last_cc{}, m_last_value{}; + int m_last_channel{}; + //! In Takeover mode, slots whose incoming controller is held back. + std::array m_locked{}; + //! Side of the stored value the last held-back value was on: -1, 0 or 1. + std::array m_side{}; + bool m_initialized{}; + bool m_stop{}; + + void send_slot(int i, int channel, int cc, int value) + { + auto m = libremidi::channel_events::control_change( + channel, std::clamp(cc, 0, 127), std::clamp(value, 0, 127)); + m.timestamp = 0; + outputs.midi.push_back(m); + m_locked[i] = inputs.mode == Takeover; + m_side[i] = 0; + } + + bool pass( + const libremidi::message& m, int channel, int count, + const std::array& ccs, const std::array& values) noexcept + { + if(inputs.mode != Takeover || m.size() < 3 || (m.bytes[0] & 0xF0) != 0xB0 + || channel_of(m) != channel) + return true; + + bool keep = true; + for(int i = 0; i < count; i++) + { + if(!m_locked[i] || ccs[i] != m.bytes[1]) + continue; + const int v = m.bytes[2]; + const int side = v < values[i] ? -1 : (v > values[i] ? 1 : 0); + if(side == 0 || (m_side[i] != 0 && side != m_side[i])) + { + m_locked[i] = false; + continue; + } + m_side[i] = int8_t(side); + keep = false; + } + return keep; + } +}; +} diff --git a/examples/Advanced/MidiScaler/MidiTransform.hpp b/examples/Advanced/MidiScaler/MidiTransform.hpp new file mode 100644 index 00000000..5bf51999 --- /dev/null +++ b/examples/Advanced/MidiScaler/MidiTransform.hpp @@ -0,0 +1,642 @@ +#pragma once + +/* SPDX-License-Identifier: GPL-3.0-or-later */ + +#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. + */ +struct MidiTransform +{ + halp_meta(name, "Midi transform") + halp_meta(c_name, "midi_transform") + halp_meta(category, "Midi") + 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") + + 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 \ + "Arg 1", "Arg 2", "Channel arg", "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<"Arg 1", halp::range{0, 127, 0}> + { + halp_meta(description, "Constant usable as a source") + } arg1; + struct : halp::spinbox_i32<"Arg 2", halp::range{0, 127, 0}> + { + halp_meta(description, "Constant usable as a source") + } arg2; + struct : halp::spinbox_i32<"Channel arg", halp::range{1, 16, 1}> + { + halp_meta(description, "Channel usable as a source") + } channel_arg; + + struct : halp::spinbox_i32<"Channel", halp::range{0, 16, 0}> + { + halp_meta(description, "Channel of the messages transformed (0 = all)") + } channel; + struct : halp::lineedit<"Byte 1", ""> + { + halp_meta(description, "First data byte range tested, e.g. 0-63 or C3-B3. Empty: all.") + } byte1_range; + struct : halp::enum_t + { + halp_meta(description, "Transform messages inside or outside the Byte 1 range") + } byte1_test; + struct : halp::lineedit<"Byte 2", ""> + { + halp_meta(description, "Second data byte range tested, e.g. 1-127. Empty: 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; + } outputs; + + 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; } + + using tick = halp::tick_musical; + void operator()(halp::tick_musical) noexcept + { + m_byte1.update(inputs.byte1_range.value, 0, 127, true); + m_byte2.update(inputs.byte2_range.value, 0, 127); + + if(m_stop) + { + m_stop = false; + end_everything(0); + } + + 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{}; + cached_range_list m_byte1, m_byte2; + bool m_stop{}; + + void emit(const libremidi::message& m) { outputs.midi.push_back(m); } + + 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(!m_byte1.list.empty() + && m_byte1.list.contains(byte(m, 1)) != (inputs.byte1_test == Inside)) + return false; + if(!m_byte2.list.empty() + && m_byte2.list.contains(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(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(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(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]; + + if(what == NoteOn) + { + const bool into_note = What(inputs.into.value) == NoteOn; + if(m_removed[c][n] > 0) + { + m_removed[c][n]--; + if(into_note) + end_produced(in, k, in.bytes[2]); + return; + } + if(m_passed[c][n] > 0) + m_passed[c][n]--; + 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; + } + + const bool kept_pair = m_passed[c][n] > 0; + if(kept_pair) + m_passed[c][n]--; + + if(what != NoteOff || !matches_filter(in)) + { + emit(in); + return; + } + + switch(mode) + { + case InsertBefore: + insert(in, k, true); + break; + case InsertAfter: + insert(in, k, false); + 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; + } + } +}; +} From 1e3cc0fa847470d076a566d66877910ac0dfb1ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Thu, 22 Oct 2026 09:31:06 -0400 Subject: [PATCH 07/36] midi: MIDI Sync In passes the clock through a divider, shift and delay A Clock out output carries the incoming clock and transport messages, divided by N, shifted by N clocks at each Start, delayed by up to a second, with or without SPP. The decoded outputs are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qr9WkcUjEGCueg7eG1KxSr --- examples/Midi/MidiSyncInput.hpp | 150 +++++++++++++++++++++++++++++++- 1 file changed, 149 insertions(+), 1 deletion(-) diff --git a/examples/Midi/MidiSyncInput.hpp b/examples/Midi/MidiSyncInput.hpp index e84b19bf..172c4ac4 100644 --- a/examples/Midi/MidiSyncInput.hpp +++ b/examples/Midi/MidiSyncInput.hpp @@ -1,7 +1,9 @@ #pragma once /* SPDX-License-Identifier: GPL-3.0-or-later */ +#include #include +#include #include #include #include @@ -10,7 +12,9 @@ #include #include +#include #include +#include #include #include @@ -31,6 +35,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 { @@ -75,6 +80,32 @@ struct MIDISyncIn 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") + } 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") + } pass_spp; } inputs; struct @@ -88,6 +119,14 @@ 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; // MTC state tracking @@ -534,7 +573,114 @@ 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); + } + + //! 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: + if(inputs.pass_spp.value) + schedule_clock_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) @@ -611,6 +757,8 @@ struct MIDISyncIn // Check for timeout check_timeout(); + + run_clock_out(frames); } }; From 7fa8bfe374a33f0e0bca2109dbe6d0800daf9dbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Thu, 22 Oct 2026 09:33:45 -0400 Subject: [PATCH 08/36] midi: Midi delay Note echoes, synced or in milliseconds, with repeats, velocity feedback, an accumulating pitch step, dry / wet, fixed length and per-note expression forwarded to the echoes. Every echo gets its note-off. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qr9WkcUjEGCueg7eG1KxSr --- examples/Advanced/MidiScaler/MidiDelay.hpp | 469 +++++++++++++++++++++ 1 file changed, 469 insertions(+) create mode 100644 examples/Advanced/MidiScaler/MidiDelay.hpp diff --git a/examples/Advanced/MidiScaler/MidiDelay.hpp b/examples/Advanced/MidiScaler/MidiDelay.hpp new file mode 100644 index 00000000..c52487a3 --- /dev/null +++ b/examples/Advanced/MidiScaler/MidiDelay.hpp @@ -0,0 +1,469 @@ +#pragma once + +/* SPDX-License-Identifier: GPL-3.0-or-later */ + +#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, 50 the source and the + * echoes at full velocity, 100 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") + 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") + + 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::hslider_i32<"Feedback", halp::irange{0, 100, 70}> + { + halp_meta(description, "Velocity of each echo, in percent of 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::hslider_i32<"Dry/Wet", halp::irange{0, 100, 50}> + { + halp_meta( + description, "0: the source only. 50: the source and the echoes. 100: " + "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; + + 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; + m_queue.schedule( + s.echo_on(k), + libremidi::channel_events::note_on(s.channel, s.echo_pitch(k), v)); + 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++; + 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)); }); + } + + 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 int mix = std::clamp(inputs.mix.value, 0, 100); + const double dry = mix <= 50 ? 1. : (100 - mix) / 50.; + const double wet = mix >= 50 ? 1. : mix / 50.; + + 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(inputs.feedback.value, 0, 100) / 100.; + 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{}; +}; +} From d90b535a2577c36fca9a6d23f3244b5808141a6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Thu, 22 Oct 2026 10:39:43 -0400 Subject: [PATCH 09/36] midi: Midi value shaper Shapes velocity, release velocity, CC, pitch bend (in 14 bits) or pressure: fixed value, rescale, curve, compression, gain with key tracking, offset, seeded random and clip, in that order. Note-ons never become note-offs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qr9WkcUjEGCueg7eG1KxSr --- .../Advanced/MidiScaler/MidiValueShaper.hpp | 461 ++++++++++++++++++ 1 file changed, 461 insertions(+) create mode 100644 examples/Advanced/MidiScaler/MidiValueShaper.hpp diff --git a/examples/Advanced/MidiScaler/MidiValueShaper.hpp b/examples/Advanced/MidiScaler/MidiValueShaper.hpp new file mode 100644 index 00000000..25a250df --- /dev/null +++ b/examples/Advanced/MidiScaler/MidiValueShaper.hpp @@ -0,0 +1,461 @@ +#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 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. + */ +struct MidiValueShaper +{ +public: + halp_meta(name, "Midi value shaper") + halp_meta(c_name, "midi_value_shaper") + halp_meta(category, "Midi") + 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") + + 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<"Number", halp::range{-1, 127, -1}> + { + halp_meta( + description, + "Controller number for CC, note for poly pressure (-1: any)") + } 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::hslider_f32<"Gain", halp::range{0., 400., 100.}> + { + halp_meta(description, "Amplification, in percent") + } 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., 200., 100.}> + { + halp_meta( + description, + "Gain in percent 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., 200., 100.}> + { + halp_meta(description, "Gain in percent at note 60") + } mid_gain; + + struct : halp::knob_f32<"High keys", halp::range{0., 200., 100.}> + { + halp_meta(description, "Gain in percent at note 96") + } high_gain; + + // ---- Limits --------------------------------------------------------- + struct : halp::range_slider_f32< + "Random", 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; + } 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 + { + const int seed = inputs.seed; + if(seed != m_seed || m_stop_requested + || (seed != 0 && t.position_in_frames < m_last_position)) + { + m_seed = seed; + m_stop_requested = false; + reseed(); + } + m_last_position = t.position_in_frames; + + for(const auto& m : inputs.midi) + { + auto out = m; + process(out); + 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)) / 100. * 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)) / 100.; + const double mid = std::max(0., double(inputs.mid_gain.value)) / 100.; + const double hi = std::max(0., double(inputs.high_gain.value)) / 100.; + 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: + void process(libremidi::message& m) noexcept + { + if(!is_channel_message(m)) + return; + if(inputs.channel != 0 && channel_of(m) != inputs.channel) + return; + + 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)); + } + 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])); + break; + + case CC: + if(status == 0xB0 && m.size() >= 3 && (number < 0 || m.bytes[1] == number) + && !is_all_notes_off(m)) + m.bytes[2] = to_7bit(shape(m.bytes[2], -1)); + 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); + } + break; + + case Pressure: + if(status == 0xD0 && m.size() >= 2) + m.bytes[1] = to_7bit(shape(m.bytes[1], -1)); + 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])); + break; + } + } + + 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::vbox) + halp::item<&ins::fixed> fixed; + halp::item<&ins::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::vbox) + halp::item<&ins::curve> curve; + halp::item<&ins::threshold> threshold; + halp::item<&ins::ratio> ratio; + halp::item<&ins::gain> gain; + halp::item<&ins::offset> offset; + } 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::vbox) + halp::item<&ins::random> random; + halp::item<&ins::seed> seed; + halp::item<&ins::clip> clip; + } 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{}; +}; +} From a8119ad5efc9b5e09a2f46d0ac0785fe437875a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Thu, 22 Oct 2026 10:39:43 -0400 Subject: [PATCH 10/36] midi: Midi Humanize chance per chord or period, euclid gate, random CC Chance can be drawn per note, per chord or per period on the musical grid, velocity can act as a probability, a euclidean gate drops notes on rests, and one CC can get a seeded random offset. stop() releases what sounds. Seeded takes are unchanged at the new controls' defaults. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qr9WkcUjEGCueg7eG1KxSr --- .../Advanced/MidiScaler/MidiHumanizer.hpp | 251 +++++++++++++++++- 1 file changed, 250 insertions(+), 1 deletion(-) diff --git a/examples/Advanced/MidiScaler/MidiHumanizer.hpp b/examples/Advanced/MidiScaler/MidiHumanizer.hpp index ee1fd492..4adbf523 100644 --- a/examples/Advanced/MidiScaler/MidiHumanizer.hpp +++ b/examples/Advanced/MidiScaler/MidiHumanizer.hpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -54,6 +55,14 @@ struct MidiHumanizer //! 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; @@ -150,6 +159,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<"Rand. 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 offset", 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 +266,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 +277,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. @@ -271,6 +369,34 @@ 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; + halp::item<&ins::chance_period> chance_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; + 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; + } 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 +686,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 +741,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 +794,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 Rand. 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 +947,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,6 +976,8 @@ 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)); @@ -856,5 +1082,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{}; }; } From 08652399ffa0859a0099959c40bd762e6d8b0f75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Thu, 22 Oct 2026 23:10:59 -0400 Subject: [PATCH 11/36] midi: grouped layouts and clearer labels for the MIDI objects Sections, tabs and grids for Midi transform, CC snapshot, value shaper, delay, MPE and Humanize, with the controls that do not apply to the current mode greyed out. Humanize's columns are titled sections so nothing is clipped. Labels: transform "Value 1/2", "Fixed channel", "Only channel", byte ranges and modes; value shaper "CC or note", "Random add"; Humanize "Random CC", "CC random range"; MPE "Release reserve (ms)". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qr9WkcUjEGCueg7eG1KxSr --- .../Advanced/MidiScaler/MidiCCSnapshot.hpp | 50 +++++++- examples/Advanced/MidiScaler/MidiDelay.hpp | 55 +++++++++ .../Advanced/MidiScaler/MidiHumanizer.hpp | 36 ++++-- examples/Advanced/MidiScaler/MidiMpe.hpp | 108 +++++++++++++++++- .../Advanced/MidiScaler/MidiTransform.hpp | 62 ++++++++-- .../Advanced/MidiScaler/MidiValueShaper.hpp | 14 ++- 6 files changed, 301 insertions(+), 24 deletions(-) diff --git a/examples/Advanced/MidiScaler/MidiCCSnapshot.hpp b/examples/Advanced/MidiScaler/MidiCCSnapshot.hpp index 2a663768..474c377e 100644 --- a/examples/Advanced/MidiScaler/MidiCCSnapshot.hpp +++ b/examples/Advanced/MidiScaler/MidiCCSnapshot.hpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -52,7 +53,7 @@ struct MidiCCSnapshot halp_meta(description, "Value sent for this slot") }; - struct + struct ins { halp::midi_bus<"MIDI", libremidi::message> midi; struct : halp::impulse_button<"Send"> @@ -102,6 +103,53 @@ struct MidiCCSnapshot halp::midi_bus<"MIDI", libremidi::message> midi; } outputs; + struct ui + { + halp_meta(name, "Midi CC snapshot") + halp_meta(layout, halp::layouts::hbox) + halp_meta(background, halp::colors::background_dark) + + struct + { + halp_meta(name, "Send") + halp_meta(layout, halp::layouts::section) + halp_meta(background, halp::colors::background_mid) + halp::item<&ins::send> send; + halp::item<&ins::on_change> on_change; + halp::item<&ins::mode> mode; + halp::item<&ins::channel> channel; + halp::item<&ins::count> count; + } send; + + struct + { + halp_meta(name, "Slots") + 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::cc1> cc1; + halp::item<&ins::value1> value1; + halp::item<&ins::cc2> cc2; + halp::item<&ins::value2> value2; + halp::item<&ins::cc3> cc3; + halp::item<&ins::value3> value3; + halp::item<&ins::cc4> cc4; + halp::item<&ins::value4> value4; + halp::item<&ins::cc5> cc5; + halp::item<&ins::value5> value5; + halp::item<&ins::cc6> cc6; + halp::item<&ins::value6> value6; + halp::item<&ins::cc7> cc7; + halp::item<&ins::value7> value7; + halp::item<&ins::cc8> cc8; + halp::item<&ins::value8> value8; + } grid; + } slot_grid; + }; + void stop() noexcept { m_stop = true; } using tick = halp::tick_musical; diff --git a/examples/Advanced/MidiScaler/MidiDelay.hpp b/examples/Advanced/MidiScaler/MidiDelay.hpp index c52487a3..b1b74968 100644 --- a/examples/Advanced/MidiScaler/MidiDelay.hpp +++ b/examples/Advanced/MidiScaler/MidiDelay.hpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -118,6 +119,60 @@ struct MidiDelay 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) + halp::item<&ins::time> time; + 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::feedback> feedback; + halp::item<&ins::floor> floor; + halp::item<&ins::pitch_step> pitch_step; + } echo; + + struct + { + halp_meta(name, "Mix") + halp_meta(layout, halp::layouts::section) + halp_meta(background, halp::colors::background_mid) + halp::item<&ins::mix> mix; + halp::item<&ins::mpe> mpe; + } mix; + + struct + { + halp_meta(name, "Length") + halp_meta(layout, halp::layouts::section) + halp_meta(background, halp::colors::background_mid) + 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::length> length; + } length; + } length; + }; + void prepare(halp::setup s) noexcept { if(s.rate > 0.) diff --git a/examples/Advanced/MidiScaler/MidiHumanizer.hpp b/examples/Advanced/MidiScaler/MidiHumanizer.hpp index 4adbf523..93692a25 100644 --- a/examples/Advanced/MidiScaler/MidiHumanizer.hpp +++ b/examples/Advanced/MidiScaler/MidiHumanizer.hpp @@ -217,14 +217,14 @@ struct MidiHumanizer } euclid_step; // ---- Controllers ---------------------------------------------------- - struct : halp::spinbox_i32<"Rand. CC", halp::range{-1, 127, -1}> + 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 offset", halp::range_slider_range{-127., 127., {0., 0.}}> + "CC random range", halp::range_slider_range{-127., 127., {0., 0.}}> { halp_meta( description, "The offset added to that controller is drawn uniformly " @@ -321,7 +321,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; @@ -337,7 +337,7 @@ 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; @@ -375,7 +375,15 @@ struct MidiHumanizer halp_meta(name, "Chance") halp_meta(layout, halp::layouts::vbox) halp::item<&ins::chance_unit> chance_unit; - halp::item<&ins::chance_period> chance_period; + 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; @@ -384,10 +392,18 @@ struct MidiHumanizer halp_meta(name, "Gate") halp_meta(layout, halp::layouts::vbox) halp::item<&ins::euclid> euclid; - 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; + struct + { + halp_meta(layout, halp::layouts::vbox) + static constexpr auto condition() + { + return halp::enabled_when<&ins::euclid, true>{}; + } + 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; + } rhythm; } gate; struct @@ -859,7 +875,7 @@ struct MidiHumanizer } } - //! The random offset of the controller selected by Rand. CC. + //! The random offset of the controller selected by Random CC. int random_cc_value(int value) noexcept { double offset = m_cc_lo; diff --git a/examples/Advanced/MidiScaler/MidiMpe.hpp b/examples/Advanced/MidiScaler/MidiMpe.hpp index 0a07c664..7738ffc8 100644 --- a/examples/Advanced/MidiScaler/MidiMpe.hpp +++ b/examples/Advanced/MidiScaler/MidiMpe.hpp @@ -152,7 +152,7 @@ struct MidiMpe halp_meta(description, "Pitch bend range in semitones of the member channels") } mpe_bend_range; - struct : halp::hslider_f32<"Release reserve", halp::range{0., 1000., 0.}> + struct : halp::hslider_f32<"Release reserve (ms)", halp::range{0., 1000., 0.}> { halp_meta( description, @@ -196,6 +196,112 @@ struct MidiMpe } seed; } inputs; + struct ui + { + halp_meta(name, "Midi MPE") + halp_meta(layout, halp::layouts::hbox) + halp_meta(background, halp::colors::background_dark) + + struct + { + halp_meta(name, "Zone") + halp_meta(layout, halp::layouts::section) + halp_meta(background, halp::colors::background_mid) + halp::item<&ins::mode> mode; + + struct + { + halp_meta(layout, halp::layouts::hbox) + + struct + { + halp_meta(layout, halp::layouts::vbox) + halp::item<&ins::members> members; + + 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; + } left; + + 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::route> route; + halp::item<&ins::send_config> send_config; + } to_mpe; + + 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; + } right; + } columns; + } zone; + + struct + { + halp_meta(layout, halp::layouts::vbox) + + struct + { + halp_meta(name, "Shaping") + halp_meta(layout, halp::layouts::section) + halp_meta(background, halp::colors::background_mid) + + struct + { + 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; + } knobs; + } shaping; + + struct + { + halp_meta(name, "Random") + halp_meta(layout, halp::layouts::section) + halp_meta(background, halp::colors::background_mid) + 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; + } expression; + }; + struct { halp::midi_bus<"MIDI", libremidi::message> midi; diff --git a/examples/Advanced/MidiScaler/MidiTransform.hpp b/examples/Advanced/MidiScaler/MidiTransform.hpp index 5bf51999..10fc3bb3 100644 --- a/examples/Advanced/MidiScaler/MidiTransform.hpp +++ b/examples/Advanced/MidiScaler/MidiTransform.hpp @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -106,7 +107,7 @@ struct MidiTransform "Song select", "Tune request", "Clock", "Start", "Continue", "Stop", \ "Active sensing", "Reset" #define MTK_SOURCE_NAMES \ - "Arg 1", "Arg 2", "Channel arg", "Data byte 1", "Data byte 2", "Incoming channel" + "Value 1", "Value 2", "Fixed channel", "Data byte 1", "Data byte 2", "Incoming channel" struct ins { @@ -200,36 +201,36 @@ struct MidiTransform halp_meta(description, "Source of the produced second data byte") } byte2_source; - struct : halp::spinbox_i32<"Arg 1", halp::range{0, 127, 0}> + struct : halp::spinbox_i32<"Value 1", halp::range{0, 127, 0}> { halp_meta(description, "Constant usable as a source") } arg1; - struct : halp::spinbox_i32<"Arg 2", halp::range{0, 127, 0}> + struct : halp::spinbox_i32<"Value 2", halp::range{0, 127, 0}> { halp_meta(description, "Constant usable as a source") } arg2; - struct : halp::spinbox_i32<"Channel arg", halp::range{1, 16, 1}> + 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<"Channel", halp::range{0, 16, 0}> + struct : halp::spinbox_i32<"Only channel", halp::range{0, 16, 0}> { halp_meta(description, "Channel of the messages transformed (0 = all)") } channel; - struct : halp::lineedit<"Byte 1", ""> + struct : halp::lineedit<"Byte 1 range", ""> { halp_meta(description, "First data byte range tested, e.g. 0-63 or C3-B3. Empty: all.") } byte1_range; - struct : halp::enum_t + struct : halp::enum_t { halp_meta(description, "Transform messages inside or outside the Byte 1 range") } byte1_test; - struct : halp::lineedit<"Byte 2", ""> + struct : halp::lineedit<"Byte 2 range", ""> { halp_meta(description, "Second data byte range tested, e.g. 1-127. Empty: all.") } byte2_range; - struct : halp::enum_t + struct : halp::enum_t { halp_meta(description, "Transform messages inside or outside the Byte 2 range") } byte2_test; @@ -251,6 +252,49 @@ struct MidiTransform halp::midi_bus<"MIDI", libremidi::message> midi; } outputs; + struct ui + { + halp_meta(name, "Midi transform") + halp_meta(layout, halp::layouts::hbox) + halp_meta(background, halp::colors::background_dark) + + struct + { + halp_meta(name, "Convert") + halp_meta(layout, halp::layouts::section) + halp_meta(background, halp::colors::background_mid) + halp::item<&ins::what> what; + halp::item<&ins::into> into; + halp::item<&ins::mode> mode; + halp::item<&ins::realtime_first> realtime_first; + } convert; + + struct + { + halp_meta(name, "Output") + halp_meta(layout, halp::layouts::section) + halp_meta(background, halp::colors::background_mid) + halp::item<&ins::channel_source> channel_source; + halp::item<&ins::byte1_source> byte1_source; + halp::item<&ins::byte2_source> byte2_source; + halp::item<&ins::arg1> arg1; + halp::item<&ins::arg2> arg2; + halp::item<&ins::channel_arg> channel_arg; + } output; + + struct + { + halp_meta(name, "Input filter") + halp_meta(layout, halp::layouts::section) + halp_meta(background, halp::colors::background_mid) + halp::item<&ins::channel> channel; + halp::item<&ins::byte1_range> byte1_range; + halp::item<&ins::byte1_test> byte1_test; + halp::item<&ins::byte2_range> byte2_range; + halp::item<&ins::byte2_test> byte2_test; + } filter; + }; + MidiTransform() noexcept { inputs.mode.value = Replace; diff --git a/examples/Advanced/MidiScaler/MidiValueShaper.hpp b/examples/Advanced/MidiScaler/MidiValueShaper.hpp index 25a250df..50f1e2c0 100644 --- a/examples/Advanced/MidiScaler/MidiValueShaper.hpp +++ b/examples/Advanced/MidiScaler/MidiValueShaper.hpp @@ -80,7 +80,7 @@ struct MidiValueShaper "controller, pitch bend, channel pressure or poly pressure") } kind; - struct : halp::spinbox_i32<"Number", halp::range{-1, 127, -1}> + struct : halp::spinbox_i32<"CC or note", halp::range{-1, 127, -1}> { halp_meta( description, @@ -168,7 +168,7 @@ struct MidiValueShaper // ---- Limits --------------------------------------------------------- struct : halp::range_slider_f32< - "Random", halp::range_slider_range{-64., 64., {0., 0.}}> + "Random add", halp::range_slider_range{-64., 64., {0., 0.}}> { halp_meta(description, "A random amount drawn uniformly in this range is added") } random; @@ -415,7 +415,15 @@ struct MidiValueShaper halp_meta(name, "Range") halp_meta(layout, halp::layouts::vbox) halp::item<&ins::fixed> fixed; - halp::item<&ins::fixed_value> fixed_value; + 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; From 698f2f630c9b010047f01b4298a836857c1f64d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Thu, 22 Oct 2026 23:10:59 -0400 Subject: [PATCH 12/36] midi: Midi filter note-offs follow their note-on, grouped layout A note-off goes to the output its note-on went to, whatever the type selection says: with Types "noteon", or the Note on filter type, the note-offs of kept notes were sent to Rejected and the notes hung. Layout in Basic / Select / Remap sections; labels "Types", "Invert ranges", "Value range", "Value mode", "Outside map". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qr9WkcUjEGCueg7eG1KxSr --- examples/Advanced/MidiScaler/MidiFilter.hpp | 67 ++++++++++++++++++--- 1 file changed, 58 insertions(+), 9 deletions(-) diff --git a/examples/Advanced/MidiScaler/MidiFilter.hpp b/examples/Advanced/MidiScaler/MidiFilter.hpp index 34db1fcd..9c760145 100644 --- a/examples/Advanced/MidiScaler/MidiFilter.hpp +++ b/examples/Advanced/MidiScaler/MidiFilter.hpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -222,7 +223,7 @@ struct cached_range_map * Messages that are not kept go to the "Rejected" output, so the object also * splits a keyboard or a set of channels. * - * Text lists ("Channels", "Notes", "CCs", "Values") are comma-separated + * Text lists ("Channels", "Notes", "CCs", "Value range") are comma-separated * inclusive ranges such as "1-4, 6"; notes accept names such as "C-1-B2, C4". * An empty list does not restrict anything. * @@ -281,7 +282,7 @@ struct MidiFilter float velocity{}; }; - struct + struct ins { halp::midi_bus<"MIDI messages", libremidi::message> midi; struct : halp::enum_t @@ -302,11 +303,11 @@ struct MidiFilter halp::toggle<"Note off to zero"> note_off_to_zero; halp::toggle<"Zero to note off"> zero_to_note_off; - struct : halp::lineedit<"Kinds", ""> + struct : halp::lineedit<"Types", ""> { halp_meta( description, - "Kinds kept, e.g. note, cc, program, bend, pressure, poly, sysex, mtc, " + "Message types kept, e.g. note, cc, program, bend, pressure, poly, sysex, mtc, " "spp, song, tune, clock, start, continue, stop, sensing, reset, or the " "groups all, channel, system, realtime, transport. A leading '-' " "removes. Empty: Filter type decides.") @@ -324,18 +325,18 @@ struct MidiFilter { halp_meta(description, "Controller numbers kept, e.g. 1, 7, 64-69. Empty: all.") } ccs; - struct : halp::toggle<"Drop in range"> + 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<"Values", ""> + 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 + struct : halp::enum_t { halp_meta(description, "Keep values inside or outside the Values list") } value_test; @@ -351,7 +352,7 @@ struct MidiFilter { halp_meta(description, "Linear controller number remap, e.g. 1-8 > 21-28. Empty: none.") } cc_map; - struct : halp::enum_t + struct : halp::enum_t { halp_meta(description, "What happens to kept messages outside a map's input range") } out_of_map; @@ -376,6 +377,52 @@ struct MidiFilter } rejected; } outputs; + struct ui + { + halp_meta(name, "Midi filter") + halp_meta(layout, halp::layouts::hbox) + halp_meta(background, halp::colors::background_dark) + + struct + { + halp_meta(name, "Basic") + halp_meta(layout, halp::layouts::section) + halp_meta(background, halp::colors::background_mid) + halp::item<&ins::filter> filter; + halp::item<&ins::channel> channel; + halp::item<&ins::index> index; + halp::item<&ins::mode> mode; + halp::item<&ins::note_off_to_zero> note_off_to_zero; + halp::item<&ins::zero_to_note_off> zero_to_note_off; + } basic; + + struct + { + halp_meta(name, "Select") + halp_meta(layout, halp::layouts::section) + halp_meta(background, halp::colors::background_mid) + halp::item<&ins::kinds> kinds; + halp::item<&ins::channels> channels; + halp::item<&ins::notes> notes; + halp::item<&ins::ccs> ccs; + halp::item<&ins::values> values; + halp::item<&ins::value_test> value_test; + halp::item<&ins::drop_in_range> drop_in_range; + } select; + + struct + { + halp_meta(name, "Remap") + halp_meta(layout, halp::layouts::section) + halp_meta(background, halp::colors::background_mid) + halp::item<&ins::channel_map> channel_map; + halp::item<&ins::note_map> note_map; + halp::item<&ins::cc_map> cc_map; + halp::item<&ins::out_of_map> out_of_map; + } remap; + + }; + ossia::small_flat_map running; ossia::small_vector out_no_vel; ossia::small_vector out_vel; @@ -649,7 +696,9 @@ struct MidiFilter pass = base && index_accepts(in, k); } - const bool to_main = kind_ok && pass; + // A note-off goes where its note-on went, whatever the type selection says, + // so that a kept note is always ended and a rejected one never is. + const bool to_main = is_tracked ? tracked.to_main : (kind_ok && pass); if(k == midi_kind::note_on) { push_held( From d010107aeaff12e41cc092b900a46e1e106bfa9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Thu, 22 Oct 2026 23:10:59 -0400 Subject: [PATCH 13/36] midi: Midi dispatcher takes a voice over instead of dropping notes With no surplus channel, a note that finds no free channel takes the one released longest ago, even within its release reserve, or else ends the oldest sounding note (in the chord modes, the one on the channel of the same rank) and takes its channel. A legato chord change longer than the grace time used to lose the whole new chord. Grouped layout; "Surplus channel", "Grace (ms)", "Release reserve (ms)". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qr9WkcUjEGCueg7eG1KxSr --- .../MidiCommon/ChannelAllocator.hpp | 24 ++++ .../Advanced/MidiScaler/MidiDispatcher.hpp | 116 ++++++++++++++++-- 2 files changed, 132 insertions(+), 8 deletions(-) diff --git a/examples/Advanced/MidiScaler/MidiCommon/ChannelAllocator.hpp b/examples/Advanced/MidiScaler/MidiCommon/ChannelAllocator.hpp index 4b4e07da..9588725d 100644 --- a/examples/Advanced/MidiScaler/MidiCommon/ChannelAllocator.hpp +++ b/examples/Advanced/MidiScaler/MidiCommon/ChannelAllocator.hpp @@ -110,6 +110,30 @@ struct channel_allocator 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 = {}; diff --git a/examples/Advanced/MidiScaler/MidiDispatcher.hpp b/examples/Advanced/MidiScaler/MidiDispatcher.hpp index a3272b60..61adb06c 100644 --- a/examples/Advanced/MidiScaler/MidiDispatcher.hpp +++ b/examples/Advanced/MidiScaler/MidiDispatcher.hpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -89,14 +90,16 @@ struct MidiDispatcher halp_meta(description, "Channels the notes are spread over, for example 1-4, 6") } channels; - struct : halp::spinbox_i32<"Surplus", halp::range{0, 16, 0}> + 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 are dropped)") + "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", halp::range{0., 200., 20.}> + struct : halp::hslider_f32<"Grace (ms)", halp::range{0., 200., 20.}> { halp_meta( description, @@ -104,7 +107,7 @@ struct MidiDispatcher "complete and dispatched") } grace; - struct : halp::hslider_f32<"Release reserve", halp::range{0., 1000., 0.}> + struct : halp::hslider_f32<"Release reserve (ms)", halp::range{0., 1000., 0.}> { halp_meta( description, @@ -127,6 +130,55 @@ struct MidiDispatcher } 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; @@ -376,11 +428,59 @@ struct MidiDispatcher } } - void start_note(int in_ch, int pitch, int vel, std::optional ch, int64_t ts) noexcept + //! 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(); - // A dropped note is still tracked, without output, so that its note-off - // is swallowed too. + m_notes.end(in_ch, in_pitch, 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) { @@ -417,7 +517,7 @@ struct MidiDispatcher 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); + start_note(m_chord[i].channel, m_chord[i].pitch, m_chord[i].velocity, ch, ts, i); } } From 5317c4cf24bee22258fa8ea8c27e0b950507e9bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Thu, 22 Oct 2026 23:10:59 -0400 Subject: [PATCH 14/36] midi: Midi scaler reads incoming bend with its own range "Input bend range" (default 2) scales the incoming pitch bend; it was read with the output Bend range, so a keyboard wheel bent 48 semitones with an MPE synth setup. Grouped layout, MIDI output controls greyed in MTS mode. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qr9WkcUjEGCueg7eG1KxSr --- examples/Advanced/MidiScaler/MidiScaler.hpp | 52 ++++++++++++++++++++- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/examples/Advanced/MidiScaler/MidiScaler.hpp b/examples/Advanced/MidiScaler/MidiScaler.hpp index 832bb652..7d695da3 100644 --- a/examples/Advanced/MidiScaler/MidiScaler.hpp +++ b/examples/Advanced/MidiScaler/MidiScaler.hpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -77,7 +78,7 @@ struct MidiScaler } }; - struct + struct ins { // This port will read a midi file halp::midi_bus<"MIDI messages"> midi; @@ -170,8 +171,55 @@ struct MidiScaler "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; } 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; + } output; + }; + struct { struct @@ -451,7 +499,7 @@ struct MidiScaler if(m.size() < 3) break; const int v = m.bytes[1] | (m.bytes[2] << 7); - m_global_bend = double(v - 8192) / 8192. * m_range; + 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); From e78aa4662494e3a280262cc32c1e3fa663c5313b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Thu, 22 Oct 2026 23:10:59 -0400 Subject: [PATCH 15/36] midi: MIDI Sync In fixes MTC 29.97 / 30, full frames, defaults - Rate code 2 is 29.97 fps and 3 is 30 fps, as the MTC spec says; they were swapped, also when picking the frame rate by hand. - Full-frame MTC sysex is decoded (it was gated on meta events), for any device id. - Sync Mode and Framerate start at MTC and clock / Auto in the host too, with readable labels. - Grouped layout: Decode and Clock out sections. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qr9WkcUjEGCueg7eG1KxSr --- examples/Midi/MidiSyncInput.hpp | 95 +++++++++++++++++++++++++++++---- 1 file changed, 85 insertions(+), 10 deletions(-) diff --git a/examples/Midi/MidiSyncInput.hpp b/examples/Midi/MidiSyncInput.hpp index 172c4ac4..11f9cd26 100644 --- a/examples/Midi/MidiSyncInput.hpp +++ b/examples/Midi/MidiSyncInput.hpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -62,18 +63,31 @@ 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}; @@ -129,6 +143,68 @@ struct MIDISyncIn } 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 { @@ -249,9 +325,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; } @@ -266,9 +342,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; } @@ -375,7 +451,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 { @@ -694,7 +769,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); } From 689528e4fae047f75976b2143e2188ed4cf8663c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Fri, 23 Oct 2026 00:18:21 -0400 Subject: [PATCH 16/36] midi: Roman-numeral progressions and Midi progression player Progressions.hpp parses the chord-symbol notation of free-midi-chords (diatonic numerals, mingus suffixes, accidentals, slash basses, rests) without allocating, realizes symbols in a key and mode, and voices them with the closest inversion. ProgressionTable.hpp holds the 194 progressions of free-midi-chords (MIT) with their moods. Midi progression player steps through a progression of the table or a custom one every N beats (locked to bars), on a trigger or on each played note; it plays voiced chords, snaps played notes to the chord or scale, or maps white keys to chord tones, and outputs the current chord, degree and root. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qr9WkcUjEGCueg7eG1KxSr --- .../MidiCommon/ProgressionTable.hpp | 473 ++++++++ .../MidiScaler/MidiCommon/Progressions.hpp | 874 ++++++++++++++ .../MidiScaler/MidiProgressionPlayer.hpp | 1069 +++++++++++++++++ 3 files changed, 2416 insertions(+) create mode 100644 examples/Advanced/MidiScaler/MidiCommon/ProgressionTable.hpp create mode 100644 examples/Advanced/MidiScaler/MidiCommon/Progressions.hpp create mode 100644 examples/Advanced/MidiScaler/MidiProgressionPlayer.hpp diff --git a/examples/Advanced/MidiScaler/MidiCommon/ProgressionTable.hpp b/examples/Advanced/MidiScaler/MidiCommon/ProgressionTable.hpp new file mode 100644 index 00000000..be228e4d --- /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 00000000..dd93b6a6 --- /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/MidiProgressionPlayer.hpp b/examples/Advanced/MidiScaler/MidiProgressionPlayer.hpp new file mode 100644 index 00000000..f9935517 --- /dev/null +++ b/examples/Advanced/MidiScaler/MidiProgressionPlayer.hpp @@ -0,0 +1,1069 @@ +#pragma once + +/* SPDX-License-Identifier: GPL-3.0-or-later */ + +#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") + 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") + + 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::hslider_i32<"Gate", halp::irange{1, 100, 100}> + { + halp_meta( + description, "Chord length in percent of the step; 100 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) + halp::item<&ins::key> key; + halp::item<&ins::mode> mode; + 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, "Advance") + halp_meta(layout, halp::layouts::section) + halp_meta(background, halp::colors::background_mid) + halp::item<&ins::advance> advance; + struct + { + halp_meta(layout, halp::layouts::vbox) + 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::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; + } advance; + + struct + { + halp_meta(name, "Chords") + halp_meta(layout, halp::layouts::section) + halp_meta(background, halp::colors::background_mid) + halp::item<&ins::chords> chords; + struct + { + halp_meta(layout, halp::layouts::vbox) + static constexpr auto condition() + { + return halp::enabled_when<&ins::chords, true>{}; + } + halp::item<&ins::center> center; + halp::item<&ins::inversion> inversion; + halp::item<&ins::bass> bass; + halp::item<&ins::notes> notes; + halp::item<&ins::velocity> velocity; + halp::item<&ins::channel> channel; + } voicing; + } chords; + }; + + 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; } + + 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(); + } + + // A rewind (or a loop) restarts the take + const bool musical = m_frames > 0 && t.end_position_in_quarters != t.start_position_in_quarters; + if(m_started + && (t.position_in_frames < m_last_end + || (musical && t.start_position_in_quarters < m_last_quarter - 1e-6))) + { + end_all(0, 0, false); + reset(); + } + m_last_end = t.position_in_frames + m_frames; + 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; + } + + //! 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. + */ + 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 = t.position_in_frames; + 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); + const int gate = std::clamp(inputs.gate.value, 1, 100); + if(length > 0 && gate < 100) + m_gate_end = m_now + ts + std::max(1, length * gate / 100); + } + } + + // 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{}; + int64_t m_last_end{}; + double m_last_quarter{-1e300}; + double m_rate{48000.}; + int m_frames{}; + bool m_started{}; + bool m_stop_requested{}; +}; +} From 6604173d1516b74d5a7b355d783607859ffa7659 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Fri, 23 Oct 2026 00:21:30 -0400 Subject: [PATCH 17/36] midi: Midi random sequencer, Turing Machine and Marbles style Two engines, one object. Turing: a shift register of 1 to 16 steps whose recirculating bit is redrawn with probability 1 - Lock, negative Lock giving the doubled inverted loop; gates from register bits like the Pulses expander. Marbles: per-step draws shaped by Spread and Bias, Deja vu replaying a loop of Length steps, Density / Regularity gates and Jitter. Steps on the musical grid (or free, or advanced by input notes or a Step impulse), quantized to a scale weighted by Steps, swing, gate length, velocity range, accent, seed; Value and Gate outputs to drive other processes. Inspired by Music Thing Modular's Turing Machine (Tom Whitwell) and Mutable Instruments Marbles (Emilie Gillet); no code taken. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qr9WkcUjEGCueg7eG1KxSr --- .../MidiScaler/MidiRandomSequencer.hpp | 1182 +++++++++++++++++ 1 file changed, 1182 insertions(+) create mode 100644 examples/Advanced/MidiScaler/MidiRandomSequencer.hpp diff --git a/examples/Advanced/MidiScaler/MidiRandomSequencer.hpp b/examples/Advanced/MidiScaler/MidiRandomSequencer.hpp new file mode 100644 index 00000000..2488b412 --- /dev/null +++ b/examples/Advanced/MidiScaler/MidiRandomSequencer.hpp @@ -0,0 +1,1182 @@ +#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 + +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 rewind, 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") + 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") + + 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::hslider_i32<"Gate length", halp::irange{1, 200, 50}> + { + halp_meta( + description, "Length of a note in percent of a step; above 100 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) + halp::item<&ins::engine> engine; + halp::item<&ins::clock> clock; + 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; + struct + { + halp_meta(layout, halp::layouts::hbox) + halp::item<&ins::step> step; + halp::item<&ins::reset> reset; + } buttons; + halp::item<&ins::seed> seed; + } clock; + + struct + { + halp_meta(name, "Turing") + 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::engine, Turing>{}; + } + halp::item<&ins::lock> lock; + halp::item<&ins::write> write; + halp::item<&ins::gate> gate; + } turing; + } turing; + + struct + { + halp_meta(name, "Marbles") + 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::engine, Marbles>{}; + } + halp::item<&ins::spread> spread; + halp::item<&ins::bias> bias; + halp::item<&ins::deja_vu> deja_vu; + } marbles; + } marbles; + + struct + { + halp_meta(name, "Rhythm") + halp_meta(layout, halp::layouts::section) + halp_meta(background, halp::colors::background_mid) + 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::section) + halp_meta(background, halp::colors::background_mid) + halp::item<&ins::root> root; + halp::item<&ins::key_mode> key_mode; + halp::item<&ins::steps> steps; + halp::item<&ins::note_range> note_range; + halp::item<&ins::octaves> octaves; + } pitch; + + struct + { + halp_meta(name, "Notes") + halp_meta(layout, halp::layouts::section) + halp_meta(background, halp::colors::background_mid) + halp::item<&ins::gate_length> gate_length; + halp::item<&ins::velocity> velocity; + halp::item<&ins::accent> accent; + halp::item<&ins::channel> channel; + } notes; + }; + + //! 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; } + + 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; + + if(!m_running) + { + m_running = true; + restart_take(); + } + else if(t.position_in_frames < m_last_position) + { + // A jump back: 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_position = t.position_in_frames; + + 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(inputs.gate_length.value, 1, 200); + 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 / 100.)); + 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 + if(port.values.size() < 16 || port.values.find(ts) != port.values.end()) + port.values[ts] = 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{}; + int64_t m_last_position{}; + 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_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{}; + int m_gate_length{50}; + 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{}; +}; +} From 4dd9007e2e2421bf6e087c2c43c39986ed45e574 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Fri, 23 Oct 2026 00:42:06 -0400 Subject: [PATCH 18/36] midi: Midi life, a Game of Life sequencer A grid of up to 32 x 16 cells whose rows are scale notes (or a drum note map); a play-head steps through the columns on the musical grid and plays the live cells, or only the cells born this generation. The world evolves once per sweep or every N steps, under Life-like rules (Life, HighLife, Seeds, Day and Night, Maze, Move or custom B/S text), from a random fill, a classic pattern or a drawn pattern, and reseeds itself when it dies or repeats. The layer shows the grid live; a click toggles a cell. No allocation in the processing path. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qr9WkcUjEGCueg7eG1KxSr --- examples/Advanced/MidiScaler/MidiLife.hpp | 1512 +++++++++++++++++++++ 1 file changed, 1512 insertions(+) create mode 100644 examples/Advanced/MidiScaler/MidiLife.hpp diff --git a/examples/Advanced/MidiScaler/MidiLife.hpp b/examples/Advanced/MidiScaler/MidiLife.hpp new file mode 100644 index 00000000..9400fb1d --- /dev/null +++ b/examples/Advanced/MidiScaler/MidiLife.hpp @@ -0,0 +1,1512 @@ +#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 + +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; +} + +//! Notes of a drum map or a melody, one per row: "36 38 42 46", "C2, D2, F#2". +struct note_map +{ + static constexpr int max_notes = max_height; + std::array notes{}; + int count{}; + bool valid{true}; + + void parse(std::string_view s) noexcept + { + count = 0; + valid = true; + std::size_t i = 0; + while(i < s.size()) + { + const char c = s[i]; + if(c == ' ' || c == ',' || c == '\t' || c == '\n' || c == '\r' || c == '/') + { + i++; + continue; + } + int note = -1; + if(c >= '0' && c <= '9') + { + int v = 0; + while(i < s.size() && s[i] >= '0' && s[i] <= '9' && v <= 127) + v = v * 10 + (s[i++] - '0'); + note = v; + } + else if(auto n = parse_note_name(s.substr(i))) + { + note = n->first; + i += n->second; + } + if(note < 0 || note > 127 || count >= max_notes) + { + valid = false; + count = 0; + return; + } + notes[count++] = uint8_t(note); + } + } +}; + +//! 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 +{ + static constexpr double width() { return 224.; } + static constexpr double height() { return 112.; } + + 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); + } + + double cell_size() const noexcept + { + const int w = std::max(1, state.width), h = std::max(1, state.height); + return std::floor(std::min(width() / w, height() / h)); + } + + 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 c = cell_size(); + const double gap = c >= 6. ? 1. : 0.; + + for(int y = 0; y < h; y++) + { + const double py = (h - 1 - y) * c; + 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 * c + gap, py + gap, c - gap, c - 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 double c = cell_size(); + if(c <= 0.) + return false; + const int x = int(px / c); + const int row = int(py / c); + 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 and rewind 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") + 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") + + 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::hslider_i32<"Gate", halp::irange{5, 100, 50}> + { + halp_meta(description, "Length of a note, in percent of a step") + } gate; + struct : halp::hslider_i32<"Swing", halp::irange{0, 100, 0}> + { + halp_meta( + description, "Delays every second step: 50 puts it on the triplet, 100 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::hslider_i32<"Density", halp::irange{1, 100, 30}> + { + halp_meta(description, "Percentage 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::lineedit<"Note map", ""> + { + 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.") + } 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, "Time") + halp_meta(layout, halp::layouts::section) + halp_meta(background, halp::colors::background_mid) + halp::item<&ins::rate> rate; + halp::item<&ins::gate> gate; + halp::item<&ins::swing> swing; + 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; + } time; + + struct + { + halp_meta(name, "World") + halp_meta(layout, halp::layouts::section) + halp_meta(background, halp::colors::background_mid) + // 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; + 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::section) + halp_meta(background, halp::colors::background_mid) + halp::item<&ins::start> start; + 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::vbox) + static constexpr auto condition() + { + return halp::enabled_when<&ins::start, StartRandom>{}; + } + halp::item<&ins::density> density; + } density; + halp::item<&ins::seed> seed; + 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; + } seeding; + + struct + { + halp_meta(name, "Notes") + halp_meta(layout, halp::layouts::section) + halp_meta(background, halp::colors::background_mid) + halp::item<&ins::play> play; + halp::item<&ins::root> root; + halp::item<&ins::octave> octave; + halp::item<&ins::mode> mode; + halp::item<&ins::note_map> note_map; + halp::item<&ins::channel> channel; + } notes; + + struct + { + halp_meta(name, "Dynamics") + halp_meta(layout, halp::layouts::section) + halp_meta(background, halp::colors::background_mid) + halp::item<&ins::velocity> velocity; + halp::item<&ins::crowd> crowd; + halp::item<&ins::max_notes> max_notes; + } dynamics; + + 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; } + + 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(); + + 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 && m_frames > 0 && t.position_in_frames < m_last_end) + { + // Rewind or loop: the take starts over + end_all(0, 0); + m_queue.clear(); + restart(); + } + + if(!m_ready) + restart(); + + if(inputs.reseed.value) + m_reseed_requested = true; + + if(m_frames > 0) + { + if(!m_has_last) + m_free_next = m_now; + m_has_last = true; + m_running = true; + m_last_end = t.position_in_frames + m_frames; + 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_note_map.update(inputs.note_map.value, [](std::string_view s, auto& m) { + m.parse(s); + }); + 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) { step(m_now + frame); }); + } + else + { + if(dq < 0.) + return; + const double len = std::max(1., double(inputs.rate.value) * m_rate); + m_step_frames = len; + const int64_t end = m_now + m_frames; + int guard = 0; + while(m_free_next < end && guard++ < max_steps_per_tick) + { + step(std::max(m_free_next, m_now)); + m_free_next += int64_t(std::llround(len)); + } + } + } + + 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. + 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) { + 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)); + 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)) + 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)) + { + go = false; + return; + } + } + }); + } + + //! A step of the play-head at the absolute frame `at`. + void step(int64_t at) 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(s, inputs.swing.value / 100.) * 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(inputs.gate.value, 1, 100) / 100.)); + 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 = m_note_map.parsed; + if(map.count > 0) + return y < map.count ? map.notes[y] : -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 = m_now; + 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 uint64_t threshold + = uint64_t(std::clamp(inputs.density.value, 0, 100)) * (UINT64_MAX / 100); + 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; + } + + uint64_t hash(const life::grid& g) const noexcept + { + uint64_t h = 1469598103934665603ull ^ uint64_t(m_w) ^ (uint64_t(m_h) << 8); + for(int y = 0; y < m_h; y++) + { + h ^= g[y]; + 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_note_map; + 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{}; + int64_t m_free_next{}; + int64_t m_last_end{}; + 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_reseed_requested{}; +}; +} From ced5e288a47527a108a56c84951a22c6323e291c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Fri, 23 Oct 2026 14:01:29 -0400 Subject: [PATCH 19/36] halp: value lists, note matrices and channel masks halp::value_list holds up to N numbers or aggregates of numbers in a fixed-capacity vector, with optional column descriptions and count bounds. halp::note_matrix maps the 12 degrees of an octave to output degrees, or mutes them. halp::channel_mask holds the 16 MIDI channels as toggles. List conversion from ossia keeps the first elements of a fixed-capacity container instead of overflowing it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qr9WkcUjEGCueg7eG1KxSr --- include/avnd/binding/ossia/from_value.hpp | 8 +++- include/avnd/wrappers/widgets.hpp | 24 ++++++++++++ include/halp/channel_mask.hpp | 42 +++++++++++++++++++++ include/halp/note_matrix.hpp | 25 +++++++++++++ include/halp/value_list.hpp | 45 +++++++++++++++++++++++ 5 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 include/halp/channel_mask.hpp create mode 100644 include/halp/note_matrix.hpp create mode 100644 include/halp/value_list.hpp diff --git a/include/avnd/binding/ossia/from_value.hpp b/include/avnd/binding/ossia/from_value.hpp index 7649ba5f..a9355ae2 100644 --- a/include/avnd/binding/ossia/from_value.hpp +++ b/include/avnd/binding/ossia/from_value.hpp @@ -1090,8 +1090,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; diff --git a/include/avnd/wrappers/widgets.hpp b/include/avnd/wrappers/widgets.hpp index b358245f..5373afc0 100644 --- a/include/avnd/wrappers/widgets.hpp +++ b/include/avnd/wrappers/widgets.hpp @@ -30,6 +30,12 @@ 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, choices, xy, xy_spinbox, @@ -87,6 +93,12 @@ 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::choices: return "choices"; case widget_type::xy: @@ -234,6 +246,18 @@ 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::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 00000000..3a5c10a3 --- /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/note_matrix.hpp b/include/halp/note_matrix.hpp new file mode 100644 index 00000000..0b2f9969 --- /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/value_list.hpp b/include/halp/value_list.hpp new file mode 100644 index 00000000..477364e6 --- /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...}; +}; +} From 8e438aac6d17cd864c52b9a09288e761cfb94959 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Fri, 23 Oct 2026 14:01:29 -0400 Subject: [PATCH 20/36] midi: channel masks, map rows, byte ranges and note lists instead of text Dispatcher, Scaler and Filter channels are channel masks. The Filter's channel, note and CC maps are rows of input and output ranges, up to four, the first match applying. Transform's byte ranges are integer range spinboxes, Life's note map a list of notes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qr9WkcUjEGCueg7eG1KxSr --- .../Advanced/MidiScaler/MidiDispatcher.hpp | 13 +- examples/Advanced/MidiScaler/MidiFilter.hpp | 156 ++++++++++++------ examples/Advanced/MidiScaler/MidiLife.hpp | 62 +------ examples/Advanced/MidiScaler/MidiScaler.hpp | 14 +- .../Advanced/MidiScaler/MidiTransform.hpp | 35 ++-- 5 files changed, 145 insertions(+), 135 deletions(-) diff --git a/examples/Advanced/MidiScaler/MidiDispatcher.hpp b/examples/Advanced/MidiScaler/MidiDispatcher.hpp index 61adb06c..2ef01067 100644 --- a/examples/Advanced/MidiScaler/MidiDispatcher.hpp +++ b/examples/Advanced/MidiScaler/MidiDispatcher.hpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -85,9 +86,9 @@ struct MidiDispatcher algorithm_control algorithm; - struct : halp::lineedit<"Channels", "1-4"> + struct : halp::channel_mask<"Channels", 0x000F> { - halp_meta(description, "Channels the notes are spread over, for example 1-4, 6") + halp_meta(description, "Channels the notes are spread over") } channels; struct : halp::spinbox_i32<"Surplus channel", halp::range{0, 16, 0}> @@ -263,12 +264,7 @@ struct MidiDispatcher void configure() noexcept { - const auto& list = m_channel_list.update(inputs.channels.value, 1, 16); - uint16_t mask = 0; - for(int ch = 1; ch <= 16; ch++) - if(list.contains(ch)) - mask |= uint16_t(1u << (ch - 1)); - m_alloc.set_channels(mask); + m_alloc.set_channels(inputs.channels.mask()); const auto algo = inputs.algorithm.value; if(algo != m_algorithm) @@ -523,7 +519,6 @@ struct MidiDispatcher note_tracker<128, 1> m_notes; channel_allocator m_alloc; - cached_range_list m_channel_list; //! Note-ons sent on each channel and not yet ended. std::array m_taken{}; diff --git a/examples/Advanced/MidiScaler/MidiFilter.hpp b/examples/Advanced/MidiScaler/MidiFilter.hpp index 9c760145..94103e68 100644 --- a/examples/Advanced/MidiScaler/MidiFilter.hpp +++ b/examples/Advanced/MidiScaler/MidiFilter.hpp @@ -7,12 +7,14 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -176,43 +178,60 @@ struct cached_kind_set bool m_parsed{}; }; +//! 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 A linear map between two integer ranges, written "1-8 > 9-16". - * - * Empty or unparseable text gives an inactive map. + * @brief The linear maps of a list control: the first row whose input range + * holds a value maps it. No rows: inactive. */ -struct cached_range_map +struct range_maps { - cached_range_list in, out; - bool active{}; + static constexpr int max_rows = 4; + std::array rows{}; + int count{}; + + bool active() const noexcept { return count > 0; } - void update(std::string_view text, int min, int max, bool notes = false) noexcept + void assign(const auto& list, int min, int max) noexcept { - const auto sep = text.find('>'); - if(sep == std::string_view::npos) + count = 0; + for(range_map_row r : list) { - active = false; - return; + 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 auto& a = in.update(text.substr(0, sep), min, max, notes); - const auto& b = out.update(text.substr(sep + 1), min, max, notes); - active = in.valid && out.valid && a.count == 1 && b.count == 1; } - bool contains(int v) const noexcept + const range_map_row* find(int v) const noexcept { - return active && in.list.contains(v); + 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. - int map(int v) const noexcept - { - const auto [lo, hi] = in.list.ranges[0]; - const auto [olo, ohi] = out.list.ranges[0]; - if(hi == lo) - return olo; - const int num = 2 * (v - lo) * (ohi - olo) + (hi - lo); - return olo + num / (2 * (hi - lo)); + 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)); } }; @@ -223,9 +242,9 @@ struct cached_range_map * Messages that are not kept go to the "Rejected" output, so the object also * splits a keyboard or a set of channels. * - * Text lists ("Channels", "Notes", "CCs", "Value range") are comma-separated - * inclusive ranges such as "1-4, 6"; notes accept names such as "C-1-B2, C4". - * An empty list does not restrict anything. + * Text lists ("Notes", "CCs", "Value range") are comma-separated inclusive + * ranges such as "1-4, 6"; notes accept names such as "C-1-B2, C4". 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. @@ -312,9 +331,9 @@ struct MidiFilter "groups all, channel, system, realtime, transport. A leading '-' " "removes. Empty: Filter type decides.") } kinds; - struct : halp::lineedit<"Channels", ""> + struct : halp::channel_mask<"Channels"> { - halp_meta(description, "Channels kept, e.g. 1-4, 10. Empty: all.") + halp_meta(description, "Channels kept") } channels; struct : halp::lineedit<"Notes", ""> { @@ -340,17 +359,50 @@ struct MidiFilter { halp_meta(description, "Keep values inside or outside the Values list") } value_test; - struct : halp::lineedit<"Channel map", ""> + struct : halp::value_list<"Channel map", range_map_row, range_maps::max_rows> { - halp_meta(description, "Linear channel remap, e.g. 1-8 > 9-16. Empty: none.") + 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::lineedit<"Note map", ""> + struct : halp::value_list<"Note map", range_map_row, range_maps::max_rows> { - halp_meta(description, "Linear note remap, e.g. C3-C4 > C4-C5. Empty: none.") + 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::lineedit<"CC map", ""> + struct : halp::value_list<"CC map", range_map_row, range_maps::max_rows> { - halp_meta(description, "Linear controller number remap, e.g. 1-8 > 21-28. Empty: none.") + 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 { @@ -464,8 +516,8 @@ struct MidiFilter // Settings parsed from the text controls cached_kind_set m_kinds; - cached_range_list m_channels, m_notes, m_ccs, m_values; - cached_range_map m_channel_map, m_note_map, m_cc_map; + cached_range_list m_notes, m_ccs, 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{}; @@ -474,13 +526,12 @@ struct MidiFilter void update_settings() noexcept { m_kinds.update(inputs.kinds.value); - m_channels.update(inputs.channels.value, 1, 16); m_notes.update(inputs.notes.value, 0, 127, true); m_ccs.update(inputs.ccs.value, 0, 127); m_values.update(inputs.values.value, 0, 127); - m_channel_map.update(inputs.channel_map.value, 1, 16); - m_note_map.update(inputs.note_map.value, 0, 127, true); - m_cc_map.update(inputs.cc_map.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 @@ -595,10 +646,11 @@ struct MidiFilter const bool keyed = is_note_kind(k) || k == midi_kind::control_change; bool tested = false; bool inside = true; - if(!m_channels.list.empty()) + // Every channel on tests nothing, as an empty list + if(const uint16_t mask = inputs.channels.mask(); mask != 0xFFFF) { tested = true; - inside = inside && m_channels.list.contains(ch); + inside = inside && ((mask >> (ch - 1)) & 1); } if(is_note_kind(k) && !m_notes.list.empty() && m.size() >= 2) { @@ -631,25 +683,25 @@ struct MidiFilter return true; const bool drop = inputs.out_of_map == Drop; - if(m_channel_map.active) + if(m_channel_map.active()) { const int ch = channel_of(m); - if(m_channel_map.contains(ch)) - set_channel(m, m_channel_map.map(ch)); + 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(is_note_kind(k) && m_note_map.active() && m.size() >= 2) { - if(m_note_map.contains(m.bytes[1])) - m.bytes[1] = uint8_t(m_note_map.map(m.bytes[1])); + 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(k == midi_kind::control_change && m_cc_map.active() && m.size() >= 2) { - if(m_cc_map.contains(m.bytes[1])) - m.bytes[1] = uint8_t(m_cc_map.map(m.bytes[1])); + 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; } diff --git a/examples/Advanced/MidiScaler/MidiLife.hpp b/examples/Advanced/MidiScaler/MidiLife.hpp index 9400fb1d..bad9d5ac 100644 --- a/examples/Advanced/MidiScaler/MidiLife.hpp +++ b/examples/Advanced/MidiScaler/MidiLife.hpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include @@ -145,51 +146,6 @@ inline pattern_cells parse_pattern(std::string_view s) noexcept return p; } -//! Notes of a drum map or a melody, one per row: "36 38 42 46", "C2, D2, F#2". -struct note_map -{ - static constexpr int max_notes = max_height; - std::array notes{}; - int count{}; - bool valid{true}; - - void parse(std::string_view s) noexcept - { - count = 0; - valid = true; - std::size_t i = 0; - while(i < s.size()) - { - const char c = s[i]; - if(c == ' ' || c == ',' || c == '\t' || c == '\n' || c == '\r' || c == '/') - { - i++; - continue; - } - int note = -1; - if(c >= '0' && c <= '9') - { - int v = 0; - while(i < s.size() && s[i] >= '0' && s[i] <= '9' && v <= 127) - v = v * 10 + (s[i++] - '0'); - note = v; - } - else if(auto n = parse_note_name(s.substr(i))) - { - note = n->first; - i += n->second; - } - if(note < 0 || note > 127 || count >= max_notes) - { - valid = false; - count = 0; - return; - } - notes[count++] = uint8_t(note); - } - } -}; - //! 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 @@ -676,11 +632,15 @@ struct MidiLife }; int value{9}; } mode; - struct : halp::lineedit<"Note map", ""> + 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}> { @@ -1041,9 +1001,6 @@ struct MidiLife break; } - m_note_map.update(inputs.note_map.value, [](std::string_view s, auto& m) { - m.parse(s); - }); m_pattern.update(inputs.pattern.value, [](std::string_view s, auto& p) { p = life::parse_pattern(s); }); @@ -1225,9 +1182,9 @@ struct MidiLife //! The note of a row, or -1 when the row is silent. int pitch_of(int y) const noexcept { - const auto& map = m_note_map.parsed; - if(map.count > 0) - return y < map.count ? map.notes[y] : -1; + 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); @@ -1475,7 +1432,6 @@ struct MidiLife life::grid m_previous{}; life::rule m_rule{}; life::cached_text> m_custom_rule; - life::cached_text m_note_map; life::cached_text m_pattern; std::array m_history{}; int m_history_count{}; diff --git a/examples/Advanced/MidiScaler/MidiScaler.hpp b/examples/Advanced/MidiScaler/MidiScaler.hpp index 7d695da3..8b48657c 100644 --- a/examples/Advanced/MidiScaler/MidiScaler.hpp +++ b/examples/Advanced/MidiScaler/MidiScaler.hpp @@ -12,6 +12,7 @@ #include #include +#include #include #include #include @@ -156,12 +157,10 @@ struct MidiScaler "synths, 48 for MPE synths. Incoming pitch bend is read with the same range.") } bend_range; - struct : halp::lineedit<"Channels", "1-16"> + struct : halp::channel_mask<"Channels"> { halp_meta( - description, - "Channels the notes are spread over, for example 2-16. " - "Empty or invalid: all 16 channels.") + description, "Channels the notes are spread over. None: all 16 channels.") } channels; struct : halp::toggle<"Always send bend"> @@ -379,11 +378,7 @@ struct MidiScaler //! Bit n set: channel n + 1 is in the "Channels" control. uint16_t channel_mask() noexcept { - const auto& list = m_channel_list.update(inputs.channels.value, 1, 16); - uint16_t mask = 0; - for(int ch = 1; ch <= 16; ch++) - if(list.contains(ch)) - mask |= uint16_t(1u << (ch - 1)); + const uint16_t mask = inputs.channels.mask(); return mask == 0 ? uint16_t(0xFFFF) : mask; } @@ -614,7 +609,6 @@ struct MidiScaler note_tracker<128, 1> m_notes; channel_allocator m_alloc; - cached_range_list m_channel_list; //! Per output channel: tuning offset of its notes in semitones, last pitch //! bend sent (-1: unknown), and notes started on it. diff --git a/examples/Advanced/MidiScaler/MidiTransform.hpp b/examples/Advanced/MidiScaler/MidiTransform.hpp index 10fc3bb3..990e57df 100644 --- a/examples/Advanced/MidiScaler/MidiTransform.hpp +++ b/examples/Advanced/MidiScaler/MidiTransform.hpp @@ -218,17 +218,23 @@ struct MidiTransform { halp_meta(description, "Channel of the messages transformed (0 = all)") } channel; - struct : halp::lineedit<"Byte 1 range", ""> + 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-63 or C3-B3. Empty: all.") + 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::lineedit<"Byte 2 range", ""> + 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-127. Empty: all.") + halp_meta( + description, "Second data byte range tested, e.g. 1 to 127. The whole range: all.") } byte2_range; struct : halp::enum_t { @@ -305,11 +311,19 @@ struct MidiTransform 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 { - m_byte1.update(inputs.byte1_range.value, 0, 127, true); - m_byte2.update(inputs.byte2_range.value, 0, 127); if(m_stop) { @@ -338,7 +352,6 @@ struct MidiTransform std::array, 16> m_passed{}; //! Note-ons that were replaced or dropped, by input channel and note. std::array, 16> m_removed{}; - cached_range_list m_byte1, m_byte2; bool m_stop{}; void emit(const libremidi::message& m) { outputs.midi.push_back(m); } @@ -376,11 +389,11 @@ struct MidiTransform { if(is_channel_message(m) && inputs.channel != 0 && channel_of(m) != inputs.channel) return false; - if(!m_byte1.list.empty() - && m_byte1.list.contains(byte(m, 1)) != (inputs.byte1_test == Inside)) + if(!full(inputs.byte1_range.value) + && in_range(inputs.byte1_range.value, byte(m, 1)) != (inputs.byte1_test == Inside)) return false; - if(!m_byte2.list.empty() - && m_byte2.list.contains(byte(m, 2)) != (inputs.byte2_test == Inside)) + if(!full(inputs.byte2_range.value) + && in_range(inputs.byte2_range.value, byte(m, 2)) != (inputs.byte2_test == Inside)) return false; return true; } From 7ccc49bfa737472c8dc2ac3c9bea033678140bf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Fri, 23 Oct 2026 19:54:00 -0400 Subject: [PATCH 21/36] midi: subcategories Sequencers, Generators, Pitch, Timing, Routing, Control, Sync Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qr9WkcUjEGCueg7eG1KxSr --- examples/Advanced/MidiScaler/MidiCCSnapshot.hpp | 2 +- examples/Advanced/MidiScaler/MidiDelay.hpp | 2 +- examples/Advanced/MidiScaler/MidiDispatcher.hpp | 2 +- examples/Advanced/MidiScaler/MidiEnvelope.hpp | 2 +- examples/Advanced/MidiScaler/MidiFilter.hpp | 2 +- examples/Advanced/MidiScaler/MidiHumanizer.hpp | 2 +- examples/Advanced/MidiScaler/MidiLife.hpp | 2 +- examples/Advanced/MidiScaler/MidiMpe.hpp | 2 +- examples/Advanced/MidiScaler/MidiProgressionPlayer.hpp | 2 +- examples/Advanced/MidiScaler/MidiRandomSequencer.hpp | 2 +- examples/Advanced/MidiScaler/MidiReader.hpp | 2 +- examples/Advanced/MidiScaler/MidiScaler.hpp | 2 +- examples/Advanced/MidiScaler/MidiScroller.hpp | 2 +- examples/Advanced/MidiScaler/MidiSustain.hpp | 2 +- examples/Advanced/MidiScaler/MidiTransform.hpp | 2 +- examples/Advanced/MidiScaler/MidiValueShaper.hpp | 2 +- examples/Advanced/Patternal/Melodial.hpp | 2 +- examples/Advanced/Patternal/Patternal.hpp | 2 +- examples/Midi/MidiGlide.hpp | 2 +- examples/Midi/MidiSyncInput.hpp | 2 +- 20 files changed, 20 insertions(+), 20 deletions(-) diff --git a/examples/Advanced/MidiScaler/MidiCCSnapshot.hpp b/examples/Advanced/MidiScaler/MidiCCSnapshot.hpp index 474c377e..ff02b847 100644 --- a/examples/Advanced/MidiScaler/MidiCCSnapshot.hpp +++ b/examples/Advanced/MidiScaler/MidiCCSnapshot.hpp @@ -28,7 +28,7 @@ struct MidiCCSnapshot { halp_meta(name, "Midi CC snapshot") halp_meta(c_name, "midi_cc_snapshot") - halp_meta(category, "Midi") + halp_meta(category, "Midi/Control") halp_meta(author, "ossia score") halp_meta(description, "Send up to 8 stored controller values on a channel") halp_meta(manual_url, "https://ossia.io/score-docs/processes/midi-cc-snapshot.html") diff --git a/examples/Advanced/MidiScaler/MidiDelay.hpp b/examples/Advanced/MidiScaler/MidiDelay.hpp index b1b74968..35059c34 100644 --- a/examples/Advanced/MidiScaler/MidiDelay.hpp +++ b/examples/Advanced/MidiScaler/MidiDelay.hpp @@ -44,7 +44,7 @@ struct MidiDelay { halp_meta(name, "Midi delay") halp_meta(c_name, "midi_delay") - halp_meta(category, "Midi") + 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") diff --git a/examples/Advanced/MidiScaler/MidiDispatcher.hpp b/examples/Advanced/MidiScaler/MidiDispatcher.hpp index 2ef01067..03f1d945 100644 --- a/examples/Advanced/MidiScaler/MidiDispatcher.hpp +++ b/examples/Advanced/MidiScaler/MidiDispatcher.hpp @@ -35,7 +35,7 @@ struct MidiDispatcher { halp_meta(name, "Midi dispatcher") halp_meta(c_name, "midi_dispatcher") - halp_meta(category, "Midi") + 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") diff --git a/examples/Advanced/MidiScaler/MidiEnvelope.hpp b/examples/Advanced/MidiScaler/MidiEnvelope.hpp index 2de4ad62..6c982e91 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") diff --git a/examples/Advanced/MidiScaler/MidiFilter.hpp b/examples/Advanced/MidiScaler/MidiFilter.hpp index 94103e68..2e7f9e22 100644 --- a/examples/Advanced/MidiScaler/MidiFilter.hpp +++ b/examples/Advanced/MidiScaler/MidiFilter.hpp @@ -255,7 +255,7 @@ 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(category, "Midi/Routing") halp_meta(author, "ossia score") halp_meta( description, diff --git a/examples/Advanced/MidiScaler/MidiHumanizer.hpp b/examples/Advanced/MidiScaler/MidiHumanizer.hpp index 93692a25..5b44d01f 100644 --- a/examples/Advanced/MidiScaler/MidiHumanizer.hpp +++ b/examples/Advanced/MidiScaler/MidiHumanizer.hpp @@ -44,7 +44,7 @@ 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") diff --git a/examples/Advanced/MidiScaler/MidiLife.hpp b/examples/Advanced/MidiScaler/MidiLife.hpp index bad9d5ac..6d0bf65f 100644 --- a/examples/Advanced/MidiScaler/MidiLife.hpp +++ b/examples/Advanced/MidiScaler/MidiLife.hpp @@ -388,7 +388,7 @@ struct MidiLife { halp_meta(name, "Midi life") halp_meta(c_name, "midi_life") - halp_meta(category, "Midi") + 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 " diff --git a/examples/Advanced/MidiScaler/MidiMpe.hpp b/examples/Advanced/MidiScaler/MidiMpe.hpp index 7738ffc8..75967714 100644 --- a/examples/Advanced/MidiScaler/MidiMpe.hpp +++ b/examples/Advanced/MidiScaler/MidiMpe.hpp @@ -35,7 +35,7 @@ struct MidiMpe { halp_meta(name, "Midi MPE") halp_meta(c_name, "midi_mpe") - halp_meta(category, "Midi") + halp_meta(category, "Midi/Routing") halp_meta(author, "ossia score") halp_meta( description, diff --git a/examples/Advanced/MidiScaler/MidiProgressionPlayer.hpp b/examples/Advanced/MidiScaler/MidiProgressionPlayer.hpp index f9935517..478555e4 100644 --- a/examples/Advanced/MidiScaler/MidiProgressionPlayer.hpp +++ b/examples/Advanced/MidiScaler/MidiProgressionPlayer.hpp @@ -58,7 +58,7 @@ struct MidiProgressionPlayer { halp_meta(name, "Midi progression player") halp_meta(c_name, "midi_progression_player") - halp_meta(category, "Midi") + 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 " diff --git a/examples/Advanced/MidiScaler/MidiRandomSequencer.hpp b/examples/Advanced/MidiScaler/MidiRandomSequencer.hpp index 2488b412..1875f8da 100644 --- a/examples/Advanced/MidiScaler/MidiRandomSequencer.hpp +++ b/examples/Advanced/MidiScaler/MidiRandomSequencer.hpp @@ -72,7 +72,7 @@ struct MidiRandomSequencer { halp_meta(name, "Midi random sequencer") halp_meta(c_name, "midi_random_sequencer") - halp_meta(category, "Midi") + halp_meta(category, "Midi/Generators") halp_meta(author, "ossia score") halp_meta( description, "Random melodies from a shift register (Turing Machine) or a " diff --git a/examples/Advanced/MidiScaler/MidiReader.hpp b/examples/Advanced/MidiScaler/MidiReader.hpp index 6e168da2..18cae5ff 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 8b48657c..2a71cd82 100644 --- a/examples/Advanced/MidiScaler/MidiScaler.hpp +++ b/examples/Advanced/MidiScaler/MidiScaler.hpp @@ -45,7 +45,7 @@ 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(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 output_mode_control diff --git a/examples/Advanced/MidiScaler/MidiScroller.hpp b/examples/Advanced/MidiScaler/MidiScroller.hpp index e1f00cbe..7a131b5a 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 index 7f3412c5..b86c9d58 100644 --- a/examples/Advanced/MidiScaler/MidiSustain.hpp +++ b/examples/Advanced/MidiScaler/MidiSustain.hpp @@ -37,7 +37,7 @@ struct MidiSustain { halp_meta(name, "Midi sustain") halp_meta(c_name, "midi_sustain") - halp_meta(category, "Midi") + 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") diff --git a/examples/Advanced/MidiScaler/MidiTransform.hpp b/examples/Advanced/MidiScaler/MidiTransform.hpp index 990e57df..6a7de8c0 100644 --- a/examples/Advanced/MidiScaler/MidiTransform.hpp +++ b/examples/Advanced/MidiScaler/MidiTransform.hpp @@ -45,7 +45,7 @@ struct MidiTransform { halp_meta(name, "Midi transform") halp_meta(c_name, "midi_transform") - halp_meta(category, "Midi") + halp_meta(category, "Midi/Routing") halp_meta(author, "ossia score") halp_meta( description, diff --git a/examples/Advanced/MidiScaler/MidiValueShaper.hpp b/examples/Advanced/MidiScaler/MidiValueShaper.hpp index 50f1e2c0..df3bf5e7 100644 --- a/examples/Advanced/MidiScaler/MidiValueShaper.hpp +++ b/examples/Advanced/MidiScaler/MidiValueShaper.hpp @@ -44,7 +44,7 @@ struct MidiValueShaper public: halp_meta(name, "Midi value shaper") halp_meta(c_name, "midi_value_shaper") - halp_meta(category, "Midi") + halp_meta(category, "Midi/Control") halp_meta(author, "ossia score") halp_meta( description, diff --git a/examples/Advanced/Patternal/Melodial.hpp b/examples/Advanced/Patternal/Melodial.hpp index 539fe5d4..90f21f58 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 cfcca38d..2a18d45c 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 c5d775b2..261d878d 100644 --- a/examples/Midi/MidiGlide.hpp +++ b/examples/Midi/MidiGlide.hpp @@ -14,7 +14,7 @@ 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( diff --git a/examples/Midi/MidiSyncInput.hpp b/examples/Midi/MidiSyncInput.hpp index 11f9cd26..78ef42a8 100644 --- a/examples/Midi/MidiSyncInput.hpp +++ b/examples/Midi/MidiSyncInput.hpp @@ -31,7 +31,7 @@ struct MIDISyncIn { halp_meta(name, "MIDI Sync In") halp_meta(c_name, "midi_sync_input") - halp_meta(category, "Timing/Midi") + halp_meta(category, "Midi/Sync") halp_meta(author, "ossia team") halp_meta(description, "Extract timecode from MIDI Time Code messages") halp_meta(manual_url, "https://ossia.io/score-docs/processes/midi-timecode-input.html") From 371256bc74e97b69543a1965d7dd0b38a670633b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Fri, 23 Oct 2026 19:58:38 -0400 Subject: [PATCH 22/36] midi: MIDI Sync In stays in Timing/Midi with the other sync objects Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qr9WkcUjEGCueg7eG1KxSr --- examples/Midi/MidiSyncInput.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/Midi/MidiSyncInput.hpp b/examples/Midi/MidiSyncInput.hpp index 78ef42a8..11f9cd26 100644 --- a/examples/Midi/MidiSyncInput.hpp +++ b/examples/Midi/MidiSyncInput.hpp @@ -31,7 +31,7 @@ struct MIDISyncIn { halp_meta(name, "MIDI Sync In") halp_meta(c_name, "midi_sync_input") - halp_meta(category, "Midi/Sync") + halp_meta(category, "Timing/Midi") halp_meta(author, "ossia team") halp_meta(description, "Extract timecode from MIDI Time Code messages") halp_meta(manual_url, "https://ossia.io/score-docs/processes/midi-timecode-input.html") From b18c33aee7906a6acd49c9cce485df81f9b13d3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Fri, 23 Oct 2026 21:54:25 -0400 Subject: [PATCH 23/36] midi: MIDI Sync In, a full MTC frame restarts the timecode decoding Quarter frames received before a full frame could complete later and overwrite the new position, and clocks counted before it were added on top. A full frame resets the quarter-frame assembly and the clock interpolation. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qr9WkcUjEGCueg7eG1KxSr --- examples/Midi/MidiSyncInput.hpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/examples/Midi/MidiSyncInput.hpp b/examples/Midi/MidiSyncInput.hpp index 11f9cd26..5caa5922 100644 --- a/examples/Midi/MidiSyncInput.hpp +++ b/examples/Midi/MidiSyncInput.hpp @@ -468,6 +468,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(); } } From 7daa77855df2fd6ff31ad2d9ce89400509d7ab29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Fri, 23 Oct 2026 21:54:25 -0400 Subject: [PATCH 24/36] midi: compact layouts, 0-1 proportions, stop on process end, review fixes - Layouts: tabs and grids for Filter, Transform, MPE, Delay, Humanizer, CC snapshot, Value shaper, Life, Random sequencer, Progression player. Midi life's grid fills its view at every size. - Proportions are 0-1 knobs (Delay feedback and dry/wet, Life gate, swing and density, gates, Value shaper gains 0-4 and 0-2). - halp_flag(process_exec) on every object whose stop() releases notes: without it the host never calls stop(). - Transform: a produced note ends when What or Into changes. Dispatcher and MPE: note-offs passed through do not free a busy channel. Sustain: Chord mode ends the chord held at pedal down. Delay: expression echoes cannot crowd note echoes out of the queue (EventQueue reports drops, make_room). Value shaper: seed 0 keeps running across a stop. Life: no burst when switching sync off, swing on the grid's odd steps, no drift in free mode. Scaler: MTS sysex fits libremidi's inline storage on 32-bit targets. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qr9WkcUjEGCueg7eG1KxSr --- .../Advanced/MidiScaler/MidiCCSnapshot.hpp | 11 +- .../MidiScaler/MidiCommon/EventQueue.hpp | 25 +- examples/Advanced/MidiScaler/MidiDelay.hpp | 101 ++++--- .../Advanced/MidiScaler/MidiDispatcher.hpp | 4 +- examples/Advanced/MidiScaler/MidiEnvelope.hpp | 4 +- examples/Advanced/MidiScaler/MidiFilter.hpp | 77 +++-- .../Advanced/MidiScaler/MidiHumanizer.hpp | 29 +- examples/Advanced/MidiScaler/MidiLife.hpp | 279 ++++++++++-------- examples/Advanced/MidiScaler/MidiMpe.hpp | 152 +++++----- .../MidiScaler/MidiProgressionPlayer.hpp | 122 ++++---- .../MidiScaler/MidiRandomSequencer.hpp | 131 ++++---- examples/Advanced/MidiScaler/MidiScaler.hpp | 18 +- examples/Advanced/MidiScaler/MidiSustain.hpp | 6 +- .../Advanced/MidiScaler/MidiTransform.hpp | 106 ++++--- .../Advanced/MidiScaler/MidiValueShaper.hpp | 53 ++-- 15 files changed, 660 insertions(+), 458 deletions(-) diff --git a/examples/Advanced/MidiScaler/MidiCCSnapshot.hpp b/examples/Advanced/MidiScaler/MidiCCSnapshot.hpp index ff02b847..6c8f2008 100644 --- a/examples/Advanced/MidiScaler/MidiCCSnapshot.hpp +++ b/examples/Advanced/MidiScaler/MidiCCSnapshot.hpp @@ -33,6 +33,7 @@ struct MidiCCSnapshot halp_meta(description, "Send up to 8 stored controller values on a channel") halp_meta(manual_url, "https://ossia.io/score-docs/processes/midi-cc-snapshot.html") halp_meta(uuid, "e941cff0-8ebb-4259-a146-29ce10125791") + halp_flag(process_exec); static constexpr int slots = 8; @@ -117,8 +118,12 @@ struct MidiCCSnapshot halp::item<&ins::send> send; halp::item<&ins::on_change> on_change; halp::item<&ins::mode> mode; - halp::item<&ins::channel> channel; - halp::item<&ins::count> count; + struct + { + halp_meta(layout, halp::layouts::hbox) + halp::item<&ins::channel> channel; + halp::item<&ins::count> count; + } numbers; } send; struct @@ -129,7 +134,7 @@ struct MidiCCSnapshot struct { halp_meta(layout, halp::layouts::grid) - halp_meta(columns, 2) + halp_meta(columns, 4) halp::item<&ins::cc1> cc1; halp::item<&ins::value1> value1; halp::item<&ins::cc2> cc2; diff --git a/examples/Advanced/MidiScaler/MidiCommon/EventQueue.hpp b/examples/Advanced/MidiScaler/MidiCommon/EventQueue.hpp index f7dc995e..fb09a62e 100644 --- a/examples/Advanced/MidiScaler/MidiCommon/EventQueue.hpp +++ b/examples/Advanced/MidiScaler/MidiCommon/EventQueue.hpp @@ -43,12 +43,13 @@ struct event_queue uint32_t tag{}; }; - void schedule(int64_t at, const libremidi::message& m, uint32_t tag = 0) noexcept + //! 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; + return false; int victim = -1; for(int i = m_size - 1; i >= 0; i--) { @@ -73,6 +74,25 @@ struct event_queue 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 @@ -140,6 +160,7 @@ struct event_queue 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: diff --git a/examples/Advanced/MidiScaler/MidiDelay.hpp b/examples/Advanced/MidiScaler/MidiDelay.hpp index 35059c34..fb381967 100644 --- a/examples/Advanced/MidiScaler/MidiDelay.hpp +++ b/examples/Advanced/MidiScaler/MidiDelay.hpp @@ -27,8 +27,8 @@ namespace mtk * 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, 50 the source and the - * echoes at full velocity, 100 the echoes only; in between, the other side's + * 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 @@ -49,6 +49,7 @@ struct MidiDelay 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; @@ -72,9 +73,9 @@ struct MidiDelay halp_meta(description, "Echo until the velocity falls under the floor") } infinite; - struct : halp::hslider_i32<"Feedback", halp::irange{0, 100, 70}> + struct : halp::knob_f32<"Feedback", halp::range{0., 1., 0.7}> { - halp_meta(description, "Velocity of each echo, in percent of the previous one") + halp_meta(description, "Velocity of each echo, relative to the previous one") } feedback; struct : halp::spinbox_i32<"Vel. floor", halp::irange{1, 127, 1}> @@ -89,10 +90,10 @@ struct MidiDelay "leaves the MIDI range") } pitch_step; - struct : halp::hslider_i32<"Dry/Wet", halp::irange{0, 100, 50}> + struct : halp::knob_f32<"Dry/Wet", halp::range{0., 1., 0.5}> { halp_meta( - description, "0: the source only. 50: the source and the echoes. 100: " + description, "0: the source only. 0.5: the source and the echoes. 1: " "the echoes only.") } mix; @@ -130,47 +131,50 @@ struct MidiDelay halp_meta(name, "Echo") halp_meta(layout, halp::layouts::section) halp_meta(background, halp::colors::background_mid) - halp::item<&ins::time> time; struct { - halp_meta(layout, halp::layouts::vbox) - static constexpr auto condition() + halp_meta(layout, halp::layouts::grid) + halp_meta(columns, 2) + halp::item<&ins::time> time; + halp::item<&ins::feedback> feedback; + struct { - return halp::enabled_when<&ins::infinite, false>{}; - } - halp::item<&ins::repeats> repeats; - } repeats; - halp::item<&ins::infinite> infinite; - halp::item<&ins::feedback> feedback; - halp::item<&ins::floor> floor; - halp::item<&ins::pitch_step> pitch_step; + 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, "Mix") - halp_meta(layout, halp::layouts::section) - halp_meta(background, halp::colors::background_mid) - halp::item<&ins::mix> mix; - halp::item<&ins::mpe> mpe; - } mix; - - struct - { - halp_meta(name, "Length") + halp_meta(name, "Output") halp_meta(layout, halp::layouts::section) halp_meta(background, halp::colors::background_mid) - halp::item<&ins::fixed> fixed; struct { - halp_meta(layout, halp::layouts::vbox) - static constexpr auto condition() + halp_meta(layout, halp::layouts::grid) + halp_meta(columns, 2) + halp::item<&ins::mix> mix; + struct { - return halp::enabled_when<&ins::fixed, true>{}; - } - halp::item<&ins::length> length; - } length; - } length; + 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 @@ -318,9 +322,13 @@ struct MidiDelay } if(s.echo_on(k) >= limit) break; - m_queue.schedule( - s.echo_on(k), - libremidi::channel_events::note_on(s.channel, s.echo_pitch(k), v)); + // 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()) @@ -328,6 +336,7 @@ struct MidiDelay 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( @@ -340,6 +349,14 @@ struct MidiDelay 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)); @@ -400,9 +417,9 @@ struct MidiDelay retire(*slot, ts); } - const int mix = std::clamp(inputs.mix.value, 0, 100); - const double dry = mix <= 50 ? 1. : (100 - mix) / 50.; - const double wet = mix >= 50 ? 1. : mix / 50.; + 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 = {}; @@ -418,7 +435,7 @@ struct MidiDelay 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(inputs.feedback.value, 0, 100) / 100.; + s.feedback = std::clamp(double(inputs.feedback.value), 0., 1.); s.wet = wet; if(s.delay < 1) s.done = true; diff --git a/examples/Advanced/MidiScaler/MidiDispatcher.hpp b/examples/Advanced/MidiScaler/MidiDispatcher.hpp index 03f1d945..9bb35924 100644 --- a/examples/Advanced/MidiScaler/MidiDispatcher.hpp +++ b/examples/Advanced/MidiScaler/MidiDispatcher.hpp @@ -40,6 +40,7 @@ struct MidiDispatcher 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; @@ -356,9 +357,10 @@ struct MidiDispatcher } 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; - emit(std::move(msg)); + outputs.midi.push_back(std::move(msg)); } break; } diff --git a/examples/Advanced/MidiScaler/MidiEnvelope.hpp b/examples/Advanced/MidiScaler/MidiEnvelope.hpp index 6c982e91..d072352d 100644 --- a/examples/Advanced/MidiScaler/MidiEnvelope.hpp +++ b/examples/Advanced/MidiScaler/MidiEnvelope.hpp @@ -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 2e7f9e22..fc9d978d 100644 --- a/examples/Advanced/MidiScaler/MidiFilter.hpp +++ b/examples/Advanced/MidiScaler/MidiFilter.hpp @@ -255,6 +255,7 @@ 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_flag(process_exec); halp_meta(category, "Midi/Routing") halp_meta(author, "ossia score") halp_meta( @@ -432,47 +433,71 @@ struct MidiFilter struct ui { halp_meta(name, "Midi filter") - halp_meta(layout, halp::layouts::hbox) - halp_meta(background, halp::colors::background_dark) + halp_meta(layout, halp::layouts::tabs) + halp_meta(background, halp::colors::background_mid) struct { halp_meta(name, "Basic") - halp_meta(layout, halp::layouts::section) - halp_meta(background, halp::colors::background_mid) - halp::item<&ins::filter> filter; - halp::item<&ins::channel> channel; - halp::item<&ins::index> index; - halp::item<&ins::mode> mode; - halp::item<&ins::note_off_to_zero> note_off_to_zero; - halp::item<&ins::zero_to_note_off> zero_to_note_off; + 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::section) - halp_meta(background, halp::colors::background_mid) - halp::item<&ins::kinds> kinds; - halp::item<&ins::channels> channels; - halp::item<&ins::notes> notes; - halp::item<&ins::ccs> ccs; - halp::item<&ins::values> values; - halp::item<&ins::value_test> value_test; - halp::item<&ins::drop_in_range> drop_in_range; + 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::section) - halp_meta(background, halp::colors::background_mid) - halp::item<&ins::channel_map> channel_map; - halp::item<&ins::note_map> note_map; - halp::item<&ins::cc_map> cc_map; - halp::item<&ins::out_of_map> out_of_map; + 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; diff --git a/examples/Advanced/MidiScaler/MidiHumanizer.hpp b/examples/Advanced/MidiScaler/MidiHumanizer.hpp index 5b44d01f..37f6cbcd 100644 --- a/examples/Advanced/MidiScaler/MidiHumanizer.hpp +++ b/examples/Advanced/MidiScaler/MidiHumanizer.hpp @@ -49,6 +49,7 @@ struct MidiHumanizer 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; @@ -112,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; @@ -340,11 +341,16 @@ struct MidiHumanizer 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 @@ -399,10 +405,15 @@ struct MidiHumanizer { return halp::enabled_when<&ins::euclid, true>{}; } - 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; + 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; diff --git a/examples/Advanced/MidiScaler/MidiLife.hpp b/examples/Advanced/MidiScaler/MidiLife.hpp index 6d0bf65f..7092eb20 100644 --- a/examples/Advanced/MidiScaler/MidiLife.hpp +++ b/examples/Advanced/MidiScaler/MidiLife.hpp @@ -240,8 +240,9 @@ inline bool cell(const grid& g, int x, int y) noexcept */ struct life_view { - static constexpr double width() { return 224.; } - static constexpr double height() { return 112.; } + // 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{}; @@ -273,11 +274,9 @@ struct life_view return previous_valid && !cell(previous, x, y); } - double cell_size() const noexcept - { - const int w = std::max(1, state.width), h = std::max(1, state.height); - return std::floor(std::min(width() / w, height() / h)); - } + //! 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) { @@ -297,12 +296,12 @@ struct life_view } const int w = state.width, h = state.height; - const double c = cell_size(); - const double gap = c >= 6. ? 1. : 0.; + 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) * c; + const double py = (h - 1 - y) * ch; for(int x = 0; x < w; x++) { const bool alive = cell(state.rows, x, y); @@ -329,7 +328,7 @@ struct life_view ctx.set_fill_color({255, 176, 30, 255}); } ctx.begin_path(); - ctx.draw_rect(x * c + gap, py + gap, c - gap, c - gap); + ctx.draw_rect(x * cw + gap, py + gap, cw - gap, ch - gap); ctx.fill(); } } @@ -341,11 +340,8 @@ struct life_view if(!has_state || !state.running || !on_toggle || state.width == 0 || state.height == 0) return false; - const double c = cell_size(); - if(c <= 0.) - return false; - const int x = int(px / c); - const int row = int(py / c); + 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; @@ -395,6 +391,7 @@ struct MidiLife "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 { @@ -443,15 +440,15 @@ struct MidiLife description, "Time between two steps of the play-head; synced, a note value " "on the bars' grid") } rate; - struct : halp::hslider_i32<"Gate", halp::irange{5, 100, 50}> + struct : halp::knob_f32<"Gate", halp::range{0.05, 1., 0.5}> { - halp_meta(description, "Length of a note, in percent of a step") + halp_meta(description, "Length of a note, as a proportion of a step") } gate; - struct : halp::hslider_i32<"Swing", halp::irange{0, 100, 0}> + struct : halp::knob_f32<"Swing", halp::range{0., 1., 0.}> { halp_meta( - description, "Delays every second step: 50 puts it on the triplet, 100 at " - "5/6 of the pair") + description, "Delays every second step of the grid: 0.5 puts it on the " + "triplet, 1 at 5/6 of the pair") } swing; struct { @@ -549,9 +546,9 @@ struct MidiLife description, "Rows from the top, separated by '/' or spaces: '.' a dead " "cell, 'o' or '#' a live one") } pattern; - struct : halp::hslider_i32<"Density", halp::irange{1, 100, 30}> + struct : halp::knob_f32<"Density", halp::range{0.01, 1., 0.3}> { - halp_meta(description, "Percentage of live cells in a random world") + halp_meta(description, "Proportion of live cells in a random world") } density; struct : halp::spinbox_i32<"Seed", halp::irange{0, 9999, 0}> { @@ -694,105 +691,135 @@ struct MidiLife struct { - halp_meta(name, "Time") - halp_meta(layout, halp::layouts::section) - halp_meta(background, halp::colors::background_mid) - halp::item<&ins::rate> rate; - halp::item<&ins::gate> gate; - halp::item<&ins::swing> swing; - halp::item<&ins::evolve> evolve; + 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) - static constexpr auto condition() + struct { - return halp::enabled_when<&ins::evolve, EveryNSteps>{}; - } - halp::item<&ins::steps_per_gen> steps_per_gen; - } steps_per_gen; - } time; + 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::section) - halp_meta(background, halp::colors::background_mid) - // 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; - halp::item<&ins::rule> rule; struct { + halp_meta(name, "World") halp_meta(layout, halp::layouts::vbox) - static constexpr auto condition() + struct { - return halp::enabled_when<&ins::rule, RuleCustom>{}; - } - halp::item<&ins::custom_rule> custom_rule; - } custom_rule; - } world; + 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::section) - halp_meta(background, halp::colors::background_mid) - halp::item<&ins::start> start; struct { + halp_meta(name, "Seed") halp_meta(layout, halp::layouts::vbox) - static constexpr auto condition() + struct { - return halp::enabled_when<&ins::start, StartText>{}; - } - halp::item<&ins::pattern> pattern; - } pattern; - struct - { - halp_meta(layout, halp::layouts::vbox) - static constexpr auto condition() + 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 { - return halp::enabled_when<&ins::start, StartRandom>{}; - } - halp::item<&ins::density> density; - } density; - halp::item<&ins::seed> seed; - halp::item<&ins::reseed> reseed; - halp::item<&ins::auto_reseed> auto_reseed; + 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) - static constexpr auto condition() + struct { - return halp::enabled_when<&ins::auto_reseed, true>{}; - } - halp::item<&ins::patience> patience; - } patience; - } seeding; - - struct - { - halp_meta(name, "Notes") - halp_meta(layout, halp::layouts::section) - halp_meta(background, halp::colors::background_mid) - halp::item<&ins::play> play; - halp::item<&ins::root> root; - halp::item<&ins::octave> octave; - halp::item<&ins::mode> mode; - halp::item<&ins::note_map> note_map; - halp::item<&ins::channel> channel; - } notes; + 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::section) - halp_meta(background, halp::colors::background_mid) - halp::item<&ins::velocity> velocity; - halp::item<&ins::crowd> crowd; - halp::item<&ins::max_notes> max_notes; - } dynamics; + 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 { @@ -867,8 +894,6 @@ struct MidiLife if(m_frames > 0) { - if(!m_has_last) - m_free_next = m_now; m_has_last = true; m_running = true; m_last_end = t.position_in_frames + m_frames; @@ -1034,7 +1059,10 @@ struct MidiLife 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) { step(m_now + frame); }); + 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 { @@ -1042,13 +1070,18 @@ struct MidiLife return; const double len = std::max(1., double(inputs.rate.value) * m_rate); m_step_frames = len; - const int64_t end = m_now + m_frames; + 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(std::max(m_free_next, m_now)); - m_free_next += int64_t(std::llround(len)); + 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; } } @@ -1062,6 +1095,7 @@ struct MidiLife //! 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 @@ -1072,13 +1106,13 @@ struct MidiLife return; constexpr double eps = 1e-9; int count = 0; - auto push = [&](double p) { + 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)); + f(int(fr), index); count++; return true; }; @@ -1089,7 +1123,7 @@ struct MidiLife 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)) + if(!push(k * unit + origin, k)) break; return; } @@ -1106,7 +1140,7 @@ struct MidiLife return; if(p < start) continue; - if(!push(p)) + if(!push(p, k)) { go = false; return; @@ -1115,8 +1149,9 @@ struct MidiLife }); } - //! A step of the play-head at the absolute frame `at`. - void step(int64_t at) noexcept + //! 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++; @@ -1157,10 +1192,10 @@ struct MidiLife const int played = std::min(n, max); const int64_t delay = std::llround( - swing_delay(s, inputs.swing.value / 100.) * m_step_frames); + 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(inputs.gate.value, 1, 100) / 100.)); + 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++) @@ -1200,7 +1235,8 @@ struct MidiLife m_column = -1; m_reseeds = 0; m_reseed_requested = false; - m_free_next = m_now; + m_free_next = -1.; + m_free_index = 0; m_rng = 0; seed_world(true); } @@ -1231,8 +1267,10 @@ struct MidiLife const int w = m_w, h = m_h; if(inputs.start.value == StartRandom) { - const uint64_t threshold - = uint64_t(std::clamp(inputs.density.value, 0, 100)) * (UINT64_MAX / 100); + 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) @@ -1444,7 +1482,8 @@ struct MidiLife double m_rate{48000.}; double m_step_frames{6000.}; int64_t m_now{}; - int64_t m_free_next{}; + double m_free_next{-1.}; //!< frame of the next free step; -1: from the next tick + int64_t m_free_index{}; int64_t m_last_end{}; int64_t m_step{}; uint64_t m_rng{}; diff --git a/examples/Advanced/MidiScaler/MidiMpe.hpp b/examples/Advanced/MidiScaler/MidiMpe.hpp index 75967714..fdedf4a8 100644 --- a/examples/Advanced/MidiScaler/MidiMpe.hpp +++ b/examples/Advanced/MidiScaler/MidiMpe.hpp @@ -43,6 +43,7 @@ struct MidiMpe "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; @@ -175,17 +176,17 @@ struct MidiMpe halp_meta(description, "Scales the per-note pitch bend") } bend_scale; - struct : halp::hslider_f32<"Random bend", halp::range{0., 2., 0.}> + 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::hslider_f32<"Random pressure", halp::range{0., 1., 0.}> + 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::hslider_f32<"Random slide", halp::range{0., 1., 0.}> + 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; @@ -199,107 +200,92 @@ struct MidiMpe struct ui { halp_meta(name, "Midi MPE") - halp_meta(layout, halp::layouts::hbox) - halp_meta(background, halp::colors::background_dark) + halp_meta(layout, halp::layouts::tabs) + halp_meta(background, halp::colors::background_mid) struct { halp_meta(name, "Zone") - halp_meta(layout, halp::layouts::section) - halp_meta(background, halp::colors::background_mid) - halp::item<&ins::mode> mode; + halp_meta(layout, halp::layouts::hbox) struct { - halp_meta(layout, halp::layouts::hbox) + 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) - halp::item<&ins::members> members; - - 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 + static constexpr auto condition() { - 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; - } left; + 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) - - 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; - - halp::item<&ins::midi_bend_range> midi_bend_range; - halp::item<&ins::mpe_bend_range> mpe_bend_range; - - struct + static constexpr auto condition() { - 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; - } right; - } columns; + 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(layout, halp::layouts::vbox) - + 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(name, "Shaping") - halp_meta(layout, halp::layouts::section) - halp_meta(background, halp::colors::background_mid) - - struct + halp_meta(layout, halp::layouts::vbox) + static constexpr auto condition() { - 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; - } knobs; - } shaping; + return halp::enabled_when<&ins::mode, mode_control::midi_to_mpe>{}; + } + halp::item<&ins::reserve> reserve; + } reserve; + } bend; - struct - { - halp_meta(name, "Random") - halp_meta(layout, halp::layouts::section) - halp_meta(background, halp::colors::background_mid) - 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; - } expression; + 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 @@ -338,6 +324,10 @@ struct MidiMpe }; } + //! 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; @@ -644,7 +634,7 @@ struct MidiMpe const int ch = channel_of(m); if(ch == 0 || (m_input_channel != 0 && ch != m_input_channel)) { - emit(std::move(m)); + pass(std::move(m)); return; } @@ -683,7 +673,7 @@ struct MidiMpe case midi_kind::note_off: if(!m_notes.end(ch, m.bytes[1], ts, m.bytes[2], emit)) - emit(std::move(m)); + pass(std::move(m)); break; case midi_kind::pitch_bend: diff --git a/examples/Advanced/MidiScaler/MidiProgressionPlayer.hpp b/examples/Advanced/MidiScaler/MidiProgressionPlayer.hpp index 478555e4..1cb283bc 100644 --- a/examples/Advanced/MidiScaler/MidiProgressionPlayer.hpp +++ b/examples/Advanced/MidiScaler/MidiProgressionPlayer.hpp @@ -65,6 +65,7 @@ struct MidiProgressionPlayer "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; @@ -258,10 +259,10 @@ struct MidiProgressionPlayer "Free: seconds from the start.") } step; - struct : halp::hslider_i32<"Gate", halp::irange{1, 100, 100}> + struct : halp::knob_f32<"Gate", halp::range{0.01, 1., 1.}> { halp_meta( - description, "Chord length in percent of the step; 100 holds it until " + description, "Chord length as a proportion of the step; 1 holds it until " "the next chord") } gate; @@ -408,8 +409,12 @@ struct MidiProgressionPlayer halp_meta(name, "Harmony") halp_meta(layout, halp::layouts::section) halp_meta(background, halp::colors::background_mid) - halp::item<&ins::key> key; - halp::item<&ins::mode> mode; + struct + { + halp_meta(layout, halp::layouts::hbox) + halp::item<&ins::key> key; + halp::item<&ins::mode> mode; + } key; halp::item<&ins::progression> progression; struct { @@ -426,61 +431,72 @@ struct MidiProgressionPlayer struct { - halp_meta(name, "Advance") - halp_meta(layout, halp::layouts::section) - halp_meta(background, halp::colors::background_mid) - halp::item<&ins::advance> advance; - struct - { - halp_meta(layout, halp::layouts::vbox) - 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; + 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) - static constexpr auto condition() + halp::item<&ins::advance, halp::look{.widget = halp::control_widget::combo}> + advance; + struct { - 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() + 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 { - return halp::enabled_when<&ins::advance, advance_mode::on_note>{}; - } - halp::item<&ins::consume> consume; - } note; - } advance; + 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::section) - halp_meta(background, halp::colors::background_mid) - halp::item<&ins::chords> chords; struct { + halp_meta(name, "Chords") halp_meta(layout, halp::layouts::vbox) - static constexpr auto condition() + halp::item<&ins::chords> chords; + struct { - return halp::enabled_when<&ins::chords, true>{}; - } - halp::item<&ins::center> center; - halp::item<&ins::inversion> inversion; - halp::item<&ins::bass> bass; - halp::item<&ins::notes> notes; - halp::item<&ins::velocity> velocity; - halp::item<&ins::channel> channel; - } voicing; - } chords; + 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 @@ -805,9 +821,11 @@ struct MidiProgressionPlayer const int vel = std::clamp(inputs.velocity.value, 1, 127); for(int p : m_voicing.all()) send_on(m_chord_channel, p, vel, ts); - const int gate = std::clamp(inputs.gate.value, 1, 100); - if(length > 0 && gate < 100) - m_gate_end = m_now + ts + std::max(1, length * gate / 100); + // 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)); } } diff --git a/examples/Advanced/MidiScaler/MidiRandomSequencer.hpp b/examples/Advanced/MidiScaler/MidiRandomSequencer.hpp index 1875f8da..b97bb571 100644 --- a/examples/Advanced/MidiScaler/MidiRandomSequencer.hpp +++ b/examples/Advanced/MidiScaler/MidiRandomSequencer.hpp @@ -79,6 +79,7 @@ struct MidiRandomSequencer "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; @@ -372,10 +373,10 @@ struct MidiRandomSequencer halp_meta(description, "Each note may jump up by as many octaves, at random") } octaves; - struct : halp::hslider_i32<"Gate length", halp::irange{1, 200, 50}> + struct : halp::knob_f32<"Gate length", halp::range{0.01, 2., 0.5}> { halp_meta( - description, "Length of a note in percent of a step; above 100 the " + description, "Length of a note as a proportion of a step; above 1 the " "notes overlap") } gate_length; @@ -435,36 +436,46 @@ struct MidiRandomSequencer halp_meta(name, "Clock") halp_meta(layout, halp::layouts::section) halp_meta(background, halp::colors::background_mid) - halp::item<&ins::engine> engine; - halp::item<&ins::clock> clock; - halp::item<&ins::rate> rate; struct { - halp_meta(layout, halp::layouts::vbox) - static constexpr auto condition() + 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 { - return halp::enabled_when<&ins::clock, clock_control::rate>{}; - } - halp::item<&ins::swing> swing; - } swing; - halp::item<&ins::length> length; + 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; - halp::item<&ins::seed> seed; } clock; struct { - halp_meta(name, "Turing") - halp_meta(layout, halp::layouts::section) - halp_meta(background, halp::colors::background_mid) + halp_meta(name, "Settings") + halp_meta(layout, halp::layouts::tabs) + halp_meta(background, halp::colors::background_darker) + struct { - halp_meta(layout, halp::layouts::vbox) + halp_meta(name, "Turing") + halp_meta(layout, halp::layouts::hbox) static constexpr auto condition() { return halp::enabled_when<&ins::engine, Turing>{}; @@ -473,16 +484,11 @@ struct MidiRandomSequencer halp::item<&ins::write> write; halp::item<&ins::gate> gate; } turing; - } turing; - struct - { - halp_meta(name, "Marbles") - halp_meta(layout, halp::layouts::section) - halp_meta(background, halp::colors::background_mid) struct { - halp_meta(layout, halp::layouts::vbox) + halp_meta(name, "Marbles") + halp_meta(layout, halp::layouts::hbox) static constexpr auto condition() { return halp::enabled_when<&ins::engine, Marbles>{}; @@ -491,40 +497,49 @@ struct MidiRandomSequencer halp::item<&ins::bias> bias; halp::item<&ins::deja_vu> deja_vu; } marbles; - } marbles; - struct - { - halp_meta(name, "Rhythm") - halp_meta(layout, halp::layouts::section) - halp_meta(background, halp::colors::background_mid) - halp::item<&ins::density> density; - halp::item<&ins::regularity> regularity; - halp::item<&ins::jitter> jitter; - } rhythm; + 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::section) - halp_meta(background, halp::colors::background_mid) - halp::item<&ins::root> root; - halp::item<&ins::key_mode> key_mode; - halp::item<&ins::steps> steps; - halp::item<&ins::note_range> note_range; - halp::item<&ins::octaves> octaves; - } pitch; + 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::section) - halp_meta(background, halp::colors::background_mid) - halp::item<&ins::gate_length> gate_length; - halp::item<&ins::velocity> velocity; - halp::item<&ins::accent> accent; - halp::item<&ins::channel> channel; - } notes; + 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, @@ -900,7 +915,7 @@ struct MidiRandomSequencer 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(inputs.gate_length.value, 1, 200); + 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) @@ -1002,7 +1017,7 @@ struct MidiRandomSequencer 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 / 100.)); + = 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)); @@ -1170,7 +1185,7 @@ struct MidiRandomSequencer int m_root{}; int m_low{48}, m_high{72}; int m_octaves{}; - int m_gate_length{50}; + 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{}; diff --git a/examples/Advanced/MidiScaler/MidiScaler.hpp b/examples/Advanced/MidiScaler/MidiScaler.hpp index 2a71cd82..732ffbd6 100644 --- a/examples/Advanced/MidiScaler/MidiScaler.hpp +++ b/examples/Advanced/MidiScaler/MidiScaler.hpp @@ -45,6 +45,7 @@ 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_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.") @@ -328,8 +329,21 @@ struct MidiScaler //! MIDI Tuning Standard, real-time single note tuning change: //! F0 7F 08 02 [ ]... F7. - //! Four keys per message keep each message within libremidi's inline storage. - static constexpr int mts_keys_per_message = 4; + //! 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"); template void send_mts(int64_t ts, Emit& emit) const noexcept diff --git a/examples/Advanced/MidiScaler/MidiSustain.hpp b/examples/Advanced/MidiScaler/MidiSustain.hpp index b86c9d58..dcab0b6e 100644 --- a/examples/Advanced/MidiScaler/MidiSustain.hpp +++ b/examples/Advanced/MidiScaler/MidiSustain.hpp @@ -42,6 +42,7 @@ struct MidiSustain 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 { @@ -211,9 +212,10 @@ struct MidiSustain 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. + // 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 && !m_notes[c][k].latched) + if(m_notes[c][k].sustained && (mode == Chord || !m_notes[c][k].latched)) end_note(c, k, ts); } diff --git a/examples/Advanced/MidiScaler/MidiTransform.hpp b/examples/Advanced/MidiScaler/MidiTransform.hpp index 6a7de8c0..0aae08fd 100644 --- a/examples/Advanced/MidiScaler/MidiTransform.hpp +++ b/examples/Advanced/MidiScaler/MidiTransform.hpp @@ -53,6 +53,7 @@ struct MidiTransform "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 { @@ -261,43 +262,64 @@ struct MidiTransform struct ui { halp_meta(name, "Midi transform") - halp_meta(layout, halp::layouts::hbox) - halp_meta(background, halp::colors::background_dark) + halp_meta(layout, halp::layouts::tabs) + halp_meta(background, halp::colors::background_mid) struct { halp_meta(name, "Convert") - halp_meta(layout, halp::layouts::section) - halp_meta(background, halp::colors::background_mid) - halp::item<&ins::what> what; - halp::item<&ins::into> into; - halp::item<&ins::mode> mode; - halp::item<&ins::realtime_first> realtime_first; + 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::section) - halp_meta(background, halp::colors::background_mid) - halp::item<&ins::channel_source> channel_source; - halp::item<&ins::byte1_source> byte1_source; - halp::item<&ins::byte2_source> byte2_source; - halp::item<&ins::arg1> arg1; - halp::item<&ins::arg2> arg2; - halp::item<&ins::channel_arg> channel_arg; + 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::section) - halp_meta(background, halp::colors::background_mid) + halp_meta(layout, halp::layouts::hbox) halp::item<&ins::channel> channel; - halp::item<&ins::byte1_range> byte1_range; - halp::item<&ins::byte1_test> byte1_test; - halp::item<&ins::byte2_range> byte2_range; - halp::item<&ins::byte2_test> byte2_test; + 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; }; @@ -331,6 +353,16 @@ struct MidiTransform 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); } @@ -352,6 +384,8 @@ struct MidiTransform 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); } @@ -615,18 +649,24 @@ struct MidiTransform 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(m_removed[c][n] > 0) + if(removed) { - m_removed[c][n]--; if(into_note) end_produced(in, k, in.bytes[2]); return; } - if(m_passed[c][n] > 0) - m_passed[c][n]--; const bool before = mode == InsertBefore; if(into_note && before) end_produced(in, k, in.bytes[2]); @@ -636,23 +676,21 @@ struct MidiTransform return; } - const bool kept_pair = m_passed[c][n] > 0; - if(kept_pair) - m_passed[c][n]--; - if(what != NoteOff || !matches_filter(in)) { - emit(in); + if(!removed) + emit(in); return; } switch(mode) { case InsertBefore: - insert(in, k, true); - break; case InsertAfter: - insert(in, k, false); + if(removed) + produce(in, k); + else + insert(in, k, mode == InsertBefore); break; case Replace: if(kept_pair) diff --git a/examples/Advanced/MidiScaler/MidiValueShaper.hpp b/examples/Advanced/MidiScaler/MidiValueShaper.hpp index df3bf5e7..efc64611 100644 --- a/examples/Advanced/MidiScaler/MidiValueShaper.hpp +++ b/examples/Advanced/MidiScaler/MidiValueShaper.hpp @@ -52,6 +52,7 @@ struct MidiValueShaper "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 { @@ -138,9 +139,9 @@ struct MidiValueShaper } ratio; // ---- Gain ----------------------------------------------------------- - struct : halp::hslider_f32<"Gain", halp::range{0., 400., 100.}> + struct : halp::knob_f32<"Gain", halp::range{0., 4., 1.}> { - halp_meta(description, "Amplification, in percent") + halp_meta(description, "Amplification: 1 keeps the value, 2 doubles it") } gain; struct : halp::hslider_f32<"Offset", halp::range{-127., 127., 0.}> @@ -148,22 +149,22 @@ struct MidiValueShaper halp_meta(description, "Added to the value after the gain") } offset; - struct : halp::knob_f32<"Low keys", halp::range{0., 200., 100.}> + struct : halp::knob_f32<"Low keys", halp::range{0., 2., 1.}> { halp_meta( description, - "Gain in percent at note 24, for the kinds that come with a note. " + "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., 200., 100.}> + struct : halp::knob_f32<"Mid keys", halp::range{0., 2., 1.}> { - halp_meta(description, "Gain in percent at note 60") + halp_meta(description, "Gain at note 60") } mid_gain; - struct : halp::knob_f32<"High keys", halp::range{0., 200., 100.}> + struct : halp::knob_f32<"High keys", halp::range{0., 2., 1.}> { - halp_meta(description, "Gain in percent at note 96") + halp_meta(description, "Gain at note 96") } high_gain; // ---- Limits --------------------------------------------------------- @@ -210,14 +211,15 @@ struct MidiValueShaper 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; - if(seed != m_seed || m_stop_requested - || (seed != 0 && t.position_in_frames < m_last_position)) - { - m_seed = seed; - m_stop_requested = false; + 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) @@ -267,7 +269,7 @@ struct MidiValueShaper if(ratio != 1. && n > thresh) n = thresh + (n - thresh) / ratio; - n *= std::max(0., double(in.gain.value)) / 100. * key_gain(note); + n *= std::max(0., double(in.gain.value)) * key_gain(note); d = sign * n * (d < 0. ? span_down : span_up); u = origin + d; @@ -293,9 +295,9 @@ struct MidiValueShaper { if(note < 0) return 1.; - const double lo = std::max(0., double(inputs.low_gain.value)) / 100.; - const double mid = std::max(0., double(inputs.mid_gain.value)) / 100.; - const double hi = std::max(0., double(inputs.high_gain.value)) / 100.; + 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) @@ -413,7 +415,8 @@ struct MidiValueShaper struct { halp_meta(name, "Range") - halp_meta(layout, halp::layouts::vbox) + halp_meta(layout, halp::layouts::grid) + halp_meta(columns, 2) halp::item<&ins::fixed> fixed; struct { @@ -431,12 +434,13 @@ struct MidiValueShaper struct { halp_meta(name, "Dynamics") - halp_meta(layout, halp::layouts::vbox) + halp_meta(layout, halp::layouts::grid) + halp_meta(columns, 3) + halp::item<&ins::gain> gain; halp::item<&ins::curve> curve; - halp::item<&ins::threshold> threshold; halp::item<&ins::ratio> ratio; - halp::item<&ins::gain> gain; halp::item<&ins::offset> offset; + halp::item<&ins::threshold> threshold; } dynamics; struct @@ -451,10 +455,11 @@ struct MidiValueShaper struct { halp_meta(name, "Limits") - halp_meta(layout, halp::layouts::vbox) + halp_meta(layout, halp::layouts::grid) + halp_meta(columns, 2) halp::item<&ins::random> random; - halp::item<&ins::seed> seed; halp::item<&ins::clip> clip; + halp::item<&ins::seed> seed; } limits; } shape; }; From cd62bfd201127d02aafd779081fbe1e813bc0c66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Sat, 24 Oct 2026 10:26:13 -0400 Subject: [PATCH 25/36] halp: drag payloads and timeline clips for custom items Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qr9WkcUjEGCueg7eG1KxSr --- include/halp/custom_widgets.hpp | 38 +++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/include/halp/custom_widgets.hpp b/include/halp/custom_widgets.hpp index af77e0fc..3eb770c2 100644 --- a/include/halp/custom_widgets.hpp +++ b/include/halp/custom_widgets.hpp @@ -2,6 +2,8 @@ #include #include +#include +#include HALP_MODULE_EXPORT namespace halp { @@ -24,4 +26,40 @@ 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(); } +}; + +/** + * A file 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 host makes the clip the way it would on a drop of the file. + */ +struct timeline_clip +{ + std::string file; + //! Where the clip starts, in seconds from the start of the process. + double date{}; + //! Lane under the process, for several clips made at once. + int lane{}; +}; } From b8d84aba7360feee1b81430db235cbda56dd7a43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Sat, 24 Oct 2026 18:56:45 -0400 Subject: [PATCH 26/36] midi examples: review fixes - MidiFilter: note-offs of notes rejected only for their kind go to the main output when note-offs are selected. - MidiCommon: truncated channel messages classify as other; NoteTracker can end a note at a given time, used by the dispatcher when it steals a voice. - Life, RandomSequencer, ProgressionPlayer: seeks come from transport() and from a rewind of the quarters; free steps follow the transport. - MidiSyncInput: clock out follows Song Position through divide and shift, tempo from the clocks' timestamps, quarter-frame time as of the last piece. - MidiMpe: RPN and NRPN sequences on member channels configure the input (RPN 0 sets the bend range) instead of reaching the single output channel. - MidiValueShaper: "any controller" leaves bank select, RPN/NRPN and channel mode messages alone. - MidiScaler: reads note bytes in place and reserves its frequency output. - from_value: vecN into a fixed-capacity list keeps the elements that fit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qr9WkcUjEGCueg7eG1KxSr --- .../MidiScaler/MidiCommon/Messages.hpp | 10 ++- .../MidiScaler/MidiCommon/NoteTracker.hpp | 8 ++ .../Advanced/MidiScaler/MidiDispatcher.hpp | 2 +- examples/Advanced/MidiScaler/MidiFilter.hpp | 20 +++-- examples/Advanced/MidiScaler/MidiLife.hpp | 32 ++++++-- examples/Advanced/MidiScaler/MidiMpe.hpp | 59 ++++++++++++- .../MidiScaler/MidiProgressionPlayer.hpp | 46 +++++++++-- .../MidiScaler/MidiRandomSequencer.hpp | 35 ++++++-- examples/Advanced/MidiScaler/MidiScaler.hpp | 24 ++++-- .../Advanced/MidiScaler/MidiValueShaper.hpp | 23 +++++- examples/Midi/MidiSyncInput.hpp | 82 +++++++++++++------ include/avnd/binding/ossia/from_value.hpp | 16 +++- 12 files changed, 286 insertions(+), 71 deletions(-) diff --git a/examples/Advanced/MidiScaler/MidiCommon/Messages.hpp b/examples/Advanced/MidiScaler/MidiCommon/Messages.hpp index 854aab2b..7ac5d9d2 100644 --- a/examples/Advanced/MidiScaler/MidiCommon/Messages.hpp +++ b/examples/Advanced/MidiScaler/MidiCommon/Messages.hpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -45,15 +46,20 @@ 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.size() >= 3 && m.bytes[2] == 0) ? midi_kind::note_off - : midi_kind::note_on; + return m.bytes[2] == 0 ? midi_kind::note_off : midi_kind::note_on; case 0xA0: return midi_kind::poly_pressure; case 0xB0: diff --git a/examples/Advanced/MidiScaler/MidiCommon/NoteTracker.hpp b/examples/Advanced/MidiScaler/MidiCommon/NoteTracker.hpp index 5fef9e7f..b0668910 100644 --- a/examples/Advanced/MidiScaler/MidiCommon/NoteTracker.hpp +++ b/examples/Advanced/MidiScaler/MidiCommon/NoteTracker.hpp @@ -86,6 +86,14 @@ struct note_tracker 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 { diff --git a/examples/Advanced/MidiScaler/MidiDispatcher.hpp b/examples/Advanced/MidiScaler/MidiDispatcher.hpp index 9bb35924..000ad37c 100644 --- a/examples/Advanced/MidiScaler/MidiDispatcher.hpp +++ b/examples/Advanced/MidiScaler/MidiDispatcher.hpp @@ -465,7 +465,7 @@ struct MidiDispatcher const int in_ch = m_notes[victim].channel; const int in_pitch = m_notes[victim].pitch; auto emit = emitter(); - m_notes.end(in_ch, in_pitch, ts, 0, emit); + m_notes.end_at(victim, ts, 0, emit); m_notes.begin(in_ch, in_pitch, ts, emit); m_alloc.take(ch); return ch; diff --git a/examples/Advanced/MidiScaler/MidiFilter.hpp b/examples/Advanced/MidiScaler/MidiFilter.hpp index fc9d978d..ea1f2c33 100644 --- a/examples/Advanced/MidiScaler/MidiFilter.hpp +++ b/examples/Advanced/MidiScaler/MidiFilter.hpp @@ -610,16 +610,26 @@ struct MidiFilter for(int i = 0; i < slot.count; i++) { const auto& e = slot.entries[i]; - auto off = e.to_main ? libremidi::channel_events::note_off(e.channel, e.note, 0) - : libremidi::channel_events::note_off(c, n, 0); + 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(e.to_main, off); + 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 Kinds list, or the legacy Filter type when it is empty. bool kind_accepts(const libremidi::message& m, midi_kind k) const noexcept { @@ -773,9 +783,7 @@ struct MidiFilter pass = base && index_accepts(in, k); } - // A note-off goes where its note-on went, whatever the type selection says, - // so that a kept note is always ended and a rejected one never is. - const bool to_main = is_tracked ? tracked.to_main : (kind_ok && pass); + const bool to_main = is_tracked ? off_to_main(tracked, in) : (kind_ok && pass); if(k == midi_kind::note_on) { push_held( diff --git a/examples/Advanced/MidiScaler/MidiLife.hpp b/examples/Advanced/MidiScaler/MidiLife.hpp index 7092eb20..a06e8faf 100644 --- a/examples/Advanced/MidiScaler/MidiLife.hpp +++ b/examples/Advanced/MidiScaler/MidiLife.hpp @@ -23,6 +23,7 @@ #include #include #include +#include namespace mtk { @@ -375,8 +376,8 @@ struct life_view * 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 and rewind end every - * note and restart the world from its seed. Clicking a cell of the layer + * 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. */ @@ -856,6 +857,13 @@ struct MidiLife 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()) @@ -868,6 +876,11 @@ struct MidiLife 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; @@ -878,9 +891,8 @@ struct MidiLife m_running = false; m_has_last = false; } - else if(m_has_last && m_frames > 0 && t.position_in_frames < m_last_end) + else if(m_has_last && (sought || went_back)) { - // Rewind or loop: the take starts over end_all(0, 0); m_queue.clear(); restart(); @@ -896,7 +908,7 @@ struct MidiLife { m_has_last = true; m_running = true; - m_last_end = t.position_in_frames + m_frames; + m_last_end_quarter = t.end_position_in_quarters; run_steps(t); } @@ -1378,12 +1390,15 @@ struct MidiLife 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 { - uint64_t h = 1469598103934665603ull ^ uint64_t(m_w) ^ (uint64_t(m_h) << 8); + 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]; + h ^= g[y] & mask; h *= 1099511628211ull; } return h; @@ -1484,7 +1499,7 @@ struct MidiLife 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{}; - int64_t m_last_end{}; + double m_last_end_quarter{}; int64_t m_step{}; uint64_t m_rng{}; uint64_t m_free_rng{}; @@ -1502,6 +1517,7 @@ struct MidiLife 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 index fdedf4a8..b1921164 100644 --- a/examples/Advanced/MidiScaler/MidiMpe.hpp +++ b/examples/Advanced/MidiScaler/MidiMpe.hpp @@ -412,6 +412,8 @@ struct MidiMpe m_taken = {}; reset_expression(); m_config_sent = false; + m_mpe_range = m_mpe_range_control; + m_param = {}; } uint16_t mask = 0; @@ -424,8 +426,11 @@ struct MidiMpe 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); - if(mpe_range != m_mpe_range) + // 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; } @@ -817,6 +822,13 @@ struct MidiMpe 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); @@ -830,6 +842,43 @@ struct MidiMpe } } + //! 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 @@ -950,6 +999,14 @@ struct MidiMpe 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.}; diff --git a/examples/Advanced/MidiScaler/MidiProgressionPlayer.hpp b/examples/Advanced/MidiScaler/MidiProgressionPlayer.hpp index 1cb283bc..f96a45e7 100644 --- a/examples/Advanced/MidiScaler/MidiProgressionPlayer.hpp +++ b/examples/Advanced/MidiScaler/MidiProgressionPlayer.hpp @@ -13,10 +13,12 @@ #include #include +#include #include #include #include #include +#include namespace mtk { @@ -516,6 +518,15 @@ struct MidiProgressionPlayer 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 { @@ -527,18 +538,31 @@ struct MidiProgressionPlayer m_stop_requested = false; end_all(0, 0, true); reset(); + m_free_position = 0; } - // A rewind (or a loop) restarts the take + // 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(m_started - && (t.position_in_frames < m_last_end - || (musical && t.start_position_in_quarters < m_last_quarter - 1e-6))) + 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)); } - m_last_end = t.position_in_frames + m_frames; if(musical) m_last_quarter = t.end_position_in_quarters; m_started = true; @@ -587,6 +611,7 @@ struct MidiProgressionPlayer } run_until(m_frames - 1); m_now += m_frames; + m_free_position += m_frames; } //! Notes sounding now, for the tests. @@ -705,7 +730,8 @@ struct MidiProgressionPlayer * 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. + * 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 { @@ -772,7 +798,7 @@ struct MidiProgressionPlayer else { const int64_t step = std::max(1, std::llround(step_s * m_rate)); - const int64_t pos = t.position_in_frames; + 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; @@ -1077,11 +1103,15 @@ struct MidiProgressionPlayer std::array, 16> m_sounding{}; int64_t m_now{}; - int64_t m_last_end{}; + //! 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 index b97bb571..f30b8491 100644 --- a/examples/Advanced/MidiScaler/MidiRandomSequencer.hpp +++ b/examples/Advanced/MidiScaler/MidiRandomSequencer.hpp @@ -17,10 +17,13 @@ #include #include #include +#include #include #include +#include #include #include +#include namespace mtk { @@ -65,7 +68,7 @@ namespace mtk * 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 rewind, a restart + * With a Seed, the same transport gives the same take; a seek, a restart * of the transport or Reset starts it again. */ struct MidiRandomSequencer @@ -560,6 +563,13 @@ struct MidiRandomSequencer //! 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 { @@ -578,14 +588,17 @@ struct MidiRandomSequencer 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(t.position_in_frames < m_last_position) + else if(sought || t.start_position_in_quarters < m_last_end_quarter - 1e-6) { - // A jump back: the grid starts again, and a seeded take with it + // The grid starts again, and a seeded take with it if(m_seed != 0) { end_all(0, 0); @@ -596,7 +609,7 @@ struct MidiRandomSequencer restart_clock(); } } - m_last_position = t.position_in_frames; + m_last_end_quarter = t.end_position_in_quarters; if(inputs.reset.value) restart_take(); @@ -1039,9 +1052,14 @@ struct MidiRandomSequencer template static void write_timed(Port& port, int ts, V v) noexcept { - // The map keeps 16 entries without allocating - if(port.values.size() < 16 || port.values.find(ts) != port.values.end()) - port.values[ts] = v; + // 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 @@ -1160,7 +1178,7 @@ struct MidiRandomSequencer double m_last_q{-std::numeric_limits::infinity()}; double m_free_next{-1.}; int64_t m_now{}; - int64_t m_last_position{}; + double m_last_end_quarter{}; int64_t m_step{}; int64_t m_free_index{}; int64_t m_manual_index{}; @@ -1171,6 +1189,7 @@ struct MidiRandomSequencer 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 diff --git a/examples/Advanced/MidiScaler/MidiScaler.hpp b/examples/Advanced/MidiScaler/MidiScaler.hpp index 732ffbd6..d724d07a 100644 --- a/examples/Advanced/MidiScaler/MidiScaler.hpp +++ b/examples/Advanced/MidiScaler/MidiScaler.hpp @@ -225,6 +225,8 @@ struct MidiScaler 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; @@ -272,7 +274,11 @@ struct MidiScaler std::array active = {}; - void prepare(halp::setup s) { update_tuning(); } + void prepare(halp::setup s) + { + outputs.freq.value.reserve(128); + update_tuning(); + } void stop() noexcept { m_stop = true; } @@ -285,16 +291,16 @@ struct MidiScaler // 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; diff --git a/examples/Advanced/MidiScaler/MidiValueShaper.hpp b/examples/Advanced/MidiScaler/MidiValueShaper.hpp index efc64611..0327db99 100644 --- a/examples/Advanced/MidiScaler/MidiValueShaper.hpp +++ b/examples/Advanced/MidiScaler/MidiValueShaper.hpp @@ -85,7 +85,8 @@ struct MidiValueShaper { halp_meta( description, - "Controller number for CC, note for poly pressure (-1: any)") + "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}> @@ -347,7 +348,8 @@ struct MidiValueShaper break; case CC: - if(status == 0xB0 && m.size() >= 3 && (number < 0 || m.bytes[1] == number) + 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)); break; @@ -374,6 +376,23 @@ struct MidiValueShaper } } + //! 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)); diff --git a/examples/Midi/MidiSyncInput.hpp b/examples/Midi/MidiSyncInput.hpp index 5caa5922..4de1b66c 100644 --- a/examples/Midi/MidiSyncInput.hpp +++ b/examples/Midi/MidiSyncInput.hpp @@ -100,7 +100,8 @@ struct MIDISyncIn { halp_meta( description, - "Clock out passes 1 of every N clocks, counted from each Start") + "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}> @@ -118,7 +119,10 @@ struct MIDISyncIn struct : halp::toggle<"Pass SPP", halp::default_on_toggle> { - halp_meta(description, "Clock out passes Song Position Pointer messages") + halp_meta( + description, + "Clock out passes Song Position Pointer messages, counted in the " + "divided and shifted clock") } pass_spp; } inputs; @@ -237,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; @@ -255,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; @@ -438,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(); @@ -560,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(); - - // Store interval for tempo estimation - clock_interp.tick_intervals.push_back(interval); + const double rate = sample_rate > 0. ? sample_rate : 48000.; + const double interval = double(at - clock_interp.last_clock_tick) / rate; - // 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) @@ -610,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 @@ -683,6 +693,32 @@ struct MIDISyncIn 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 { @@ -715,8 +751,7 @@ struct MIDISyncIn schedule_clock_out(at, msg); break; case 0xF2: - if(inputs.pass_spp.value) - schedule_clock_out(at, msg); + song_position_out(at, msg); break; case 0xF8: if(clocks_to_drop > 0) @@ -826,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; diff --git a/include/avnd/binding/ossia/from_value.hpp b/include/avnd/binding/ossia/from_value.hpp index a9355ae2..9e488e03 100644 --- a/include/avnd/binding/ossia/from_value.hpp +++ b/include/avnd/binding/ossia/from_value.hpp @@ -1001,6 +1001,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 +1063,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 +1076,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 +1089,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 From 5de9e186394e797cf781411e5f18c16284da7602 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Sat, 24 Oct 2026 19:32:29 -0400 Subject: [PATCH 27/36] ossia binding: MIDI outlets keep every run of a buffer A node runs once per token, and a buffer holds several when a process loops inside it or stops inside it. The outlet was cleared on each run, so the MIDI sent before a loop point, or in the last partial tick before a stop, was lost. Batching per buffer is now the default; the outlets and the batch reserve local_midi_tick_batch_reserve packets, or the buffer size, and local_midi_tick_batch false keeps the old behaviour. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qr9WkcUjEGCueg7eG1KxSr --- book/src/writing_processors/midi.md | 30 +++++++++++-------- .../binding/ossia/current_buffer_midi.hpp | 22 ++++++++++++-- include/avnd/binding/ossia/node.hpp | 17 ++++++----- tests/test_ossia_midi.cpp | 4 +++ 4 files changed, 51 insertions(+), 22 deletions(-) diff --git a/book/src/writing_processors/midi.md b/book/src/writing_processors/midi.md index f7e4b373..684ebb56 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/include/avnd/binding/ossia/current_buffer_midi.hpp b/include/avnd/binding/ossia/current_buffer_midi.hpp index 377dce77..8e9fc488 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/node.hpp b/include/avnd/binding/ossia/node.hpp index be1c43da..a18066b0 100644 --- a/include/avnd/binding/ossia/node.hpp +++ b/include/avnd/binding/ossia/node.hpp @@ -491,14 +491,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/tests/test_ossia_midi.cpp b/tests/test_ossia_midi.cpp index 31f276ad..a3e1cfdb 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 From 6b7323a97c57d5863bf8b6a2a7c9b408e57a5590 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Sat, 24 Oct 2026 21:02:49 -0400 Subject: [PATCH 28/36] halp: timeline_clip holds the files placed together in one box Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qr9WkcUjEGCueg7eG1KxSr --- include/halp/custom_widgets.hpp | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/include/halp/custom_widgets.hpp b/include/halp/custom_widgets.hpp index 3eb770c2..da679f32 100644 --- a/include/halp/custom_widgets.hpp +++ b/include/halp/custom_widgets.hpp @@ -46,20 +46,21 @@ struct drag_payload }; /** - * A file a custom item asks the host to place in the timeline next to the + * 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 host makes the clip the way it would on a drop of the file. + * The files go together in one new box, each made the way a drop of it + * would, in a slot of its own. */ struct timeline_clip { - std::string file; + std::vector files; //! Where the clip starts, in seconds from the start of the process. double date{}; - //! Lane under the process, for several clips made at once. - int lane{}; + //! Name of the box; the first file's name when empty. + std::string name; }; } From 49d8904c6e65594ad012b93323e78e154b63c6af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Sat, 24 Oct 2026 21:32:54 -0400 Subject: [PATCH 29/36] MidiGlide: note-offs on the channel of their note, a note ended on stop The sounding note's channel is kept with its pitch: a new note ends the previous one on its own channel, a release ends it only when its channel matches, all notes off clears it, and stopping the process ends it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qr9WkcUjEGCueg7eG1KxSr --- examples/Midi/MidiGlide.hpp | 78 ++++++++++++++++++++++++++++--------- 1 file changed, 59 insertions(+), 19 deletions(-) diff --git a/examples/Midi/MidiGlide.hpp b/examples/Midi/MidiGlide.hpp index 261d878d..3095b221 100644 --- a/examples/Midi/MidiGlide.hpp +++ b/examples/Midi/MidiGlide.hpp @@ -8,8 +8,18 @@ #include #include +#include + namespace mo { +/** + * Monophonic legato: one note sounds at a time. A new note-on ends the note + * that sounds, a note-off ends it only if it is its own key's. + * + * 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") @@ -20,6 +30,7 @@ struct MidiGlide 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 { @@ -31,41 +42,70 @@ struct MidiGlide halp::midi_bus<"out", libremidi::message> out; } outputs; - void operator()() + //! The sounding note ends at the start of the next tick. + void stop() noexcept { m_stop_requested = true; } + + void operator()() noexcept { - for(const auto& note : inputs.in) + if(m_stop_requested) + { + m_stop_requested = false; + end_current(0, 0); + } + + for(const auto& m : inputs.in) { - if(note.is_note_on_or_off()) + if(m.is_note_on_or_off()) { - auto vel = note.bytes[2]; - if(vel == 0 || note.get_message_type() == libremidi::message_type::NOTE_OFF) + const int channel = m.get_channel(); + const uint8_t pitch = 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) { - if(note.bytes[1] == current_note) + if(sounding() && pitch == m_note && channel == m_channel) { - outputs.out.push_back(libremidi::channel_events::note_off(note.get_channel(), current_note, 0)); - outputs.out.back().timestamp = note.timestamp; - current_note = 255; + const bool is_off = m.get_message_type() == libremidi::message_type::NOTE_OFF; + end_current(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]; + end_current(0, m.timestamp); + outputs.out.push_back(m); + m_note = pitch; + m_channel = channel; } } else { - outputs.out.push_back(note); + // All sound off and all notes off end the note at the receiver. + if(m.get_message_type() == libremidi::message_type::CONTROL_CHANGE + && (m.bytes[1] == 120 || m.bytes[1] == 123) && m.get_channel() == m_channel) + m_note = no_note; + outputs.out.push_back(m); } } } - uint8_t current_note{255}; + //! Whether a note sounds at the output. + bool sounding() const noexcept { return m_note != no_note; } + +private: + static constexpr uint8_t no_note = 255; + + void end_current(uint8_t velocity, int64_t ts) noexcept + { + if(!sounding()) + return; + outputs.out.push_back(libremidi::channel_events::note_off(m_channel, m_note, velocity)); + outputs.out.back().timestamp = ts; + m_note = no_note; + } + + uint8_t m_note{no_note}; + int m_channel{1}; + bool m_stop_requested{}; }; } From 826bab3613ff762fc1a3da1e949d0705aa78bad4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Sat, 24 Oct 2026 22:18:36 -0400 Subject: [PATCH 30/36] ossia binding: MIDI 1 inputs get sysex whole Each MIDI input reassembles the SysEx7 packets it receives into one MIDI 1 message, across the runs of a buffer and across buffers, without allocating; UMP inputs get the packets unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qr9WkcUjEGCueg7eG1KxSr --- .../binding/ossia/midi_input_assembly.hpp | 32 ++++ include/avnd/binding/ossia/node.hpp | 2 + .../binding/ossia/port_run_preprocess.hpp | 18 +- tests/test_ossia_midi.cpp | 179 ++++++++++++++++++ 4 files changed, 227 insertions(+), 4 deletions(-) create mode 100644 include/avnd/binding/ossia/midi_input_assembly.hpp 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 00000000..df123718 --- /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 a18066b0..e3559f35 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 { diff --git a/include/avnd/binding/ossia/port_run_preprocess.hpp b/include/avnd/binding/ossia/port_run_preprocess.hpp index 9d2ca00b..201f962d 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/tests/test_ossia_midi.cpp b/tests/test_ossia_midi.cpp index a3e1cfdb..163e7b37 100644 --- a/tests/test_ossia_midi.cpp +++ b/tests/test_ossia_midi.cpp @@ -268,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); +} From 310e8f1c70a611cafd7bd1dae73f2741b9958a2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Sun, 25 Oct 2026 18:15:27 -0400 Subject: [PATCH 31/36] halp: an alignment for layouts halp_meta(alignment, halp::alignments::left / center / right) on a container aligns its children; on a grid each column is as wide as its widest item, so their edges line up. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qr9WkcUjEGCueg7eG1KxSr --- book/src/advanced/ui.layout.md | 41 ++++++++++++++++++++++++++++++++++ include/halp/layout.hpp | 10 +++++++++ 2 files changed, 51 insertions(+) diff --git a/book/src/advanced/ui.layout.md b/book/src/advanced/ui.layout.md index 96fac6ff..190e411a 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/include/halp/layout.hpp b/include/halp/layout.hpp index 3acf224f..d6ba0fb7 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) From 1d074d158f2c13fba1d1ceb179bfff97c86cb17e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Sun, 25 Oct 2026 22:20:59 -0400 Subject: [PATCH 32/36] MidiHumanizer: seed 0 plays a new take after each stop The free-running seed was reloaded unchanged on every reset, so each play repeated the same take. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qr9WkcUjEGCueg7eG1KxSr --- examples/Advanced/MidiScaler/MidiHumanizer.hpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/examples/Advanced/MidiScaler/MidiHumanizer.hpp b/examples/Advanced/MidiScaler/MidiHumanizer.hpp index 37f6cbcd..bb9ae8c3 100644 --- a/examples/Advanced/MidiScaler/MidiHumanizer.hpp +++ b/examples/Advanced/MidiScaler/MidiHumanizer.hpp @@ -1009,7 +1009,11 @@ struct MidiHumanizer 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 From 79bf3410b018993a3a6cab1248256e9e34c1c1be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Sun, 25 Oct 2026 22:51:19 -0400 Subject: [PATCH 33/36] halp: grid_chooser control and next_grid_point helper A grid value is {unit, value}: free seconds (0 is none), a note value (a fraction of a whole note, 4 quarters), bars of the current signature, or a multiple of the object's own period. The ossia binding reads and writes it as vec2f, the time chooser's encoding, so a time chooser turned into a grid chooser keeps its saved values. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qr9WkcUjEGCueg7eG1KxSr --- .../ports.metadatas.widget.md | 63 ++++++++ include/avnd/binding/ossia/from_value.hpp | 35 ++++- include/avnd/binding/ossia/port_setup.hpp | 9 ++ include/avnd/binding/ossia/to_value.hpp | 9 ++ include/avnd/concepts/control.hpp | 2 + include/avnd/concepts/parameter.hpp | 11 ++ include/avnd/wrappers/widgets.hpp | 8 + include/halp/grid.hpp | 143 +++++++++++++++++ include/halp/grid_chooser.hpp | 145 ++++++++++++++++++ 9 files changed, 424 insertions(+), 1 deletion(-) create mode 100644 include/halp/grid.hpp create mode 100644 include/halp/grid_chooser.hpp diff --git a/book/src/writing_processors/ports.metadatas.widget.md b/book/src/writing_processors/ports.metadatas.widget.md index 6dde91ee..55a0e61b 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/include/avnd/binding/ossia/from_value.hpp b/include/avnd/binding/ossia/from_value.hpp index 9e488e03..5188cf58 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 @@ -1451,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; @@ -1499,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/port_setup.hpp b/include/avnd/binding/ossia/port_setup.hpp index 8d44ce3e..959fa45e 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 950e081b..fc58a630 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 023cfc1a..402a3e86 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 375f3551..cb275c78 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 5373afc0..0e561728 100644 --- a/include/avnd/wrappers/widgets.hpp +++ b/include/avnd/wrappers/widgets.hpp @@ -45,6 +45,8 @@ enum class widget_type xyzw_spinbox, color, time_chooser, + //! A quantization grid, see halp::grid_chooser + grid_chooser, folder, bargraph, range_slider, @@ -117,6 +119,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: @@ -234,6 +238,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}; diff --git a/include/halp/grid.hpp b/include/halp/grid.hpp new file mode 100644 index 00000000..51c2bdf6 --- /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 00000000..8566a1b4 --- /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; + } +}; +} From cf440927786dcdc217d588dd366c2010978a5f51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Mon, 26 Oct 2026 11:41:06 -0400 Subject: [PATCH 34/36] halp: timeline_clip says how a clip follows the tempo Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qr9WkcUjEGCueg7eG1KxSr --- book/src/advanced/ui.painting.md | 37 ++++++++++++++++++++++++++++++++ include/halp/custom_widgets.hpp | 26 +++++++++++++++++++++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/book/src/advanced/ui.painting.md b/book/src/advanced/ui.painting.md index 6637fdc7..2f57cb6c 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/include/halp/custom_widgets.hpp b/include/halp/custom_widgets.hpp index da679f32..8a27ab5a 100644 --- a/include/halp/custom_widgets.hpp +++ b/include/halp/custom_widgets.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -45,6 +46,26 @@ struct drag_payload 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 @@ -53,7 +74,8 @@ struct drag_payload * * 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. + * 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 { @@ -62,5 +84,7 @@ struct timeline_clip 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}; }; } From e952e987e6cc12aae2444d41d5f0637a60290fdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Tue, 27 Oct 2026 09:25:29 -0400 Subject: [PATCH 35/36] MIDI objects: filter by checkboxes and ranges, values out, Glide modes, no CC snapshot - Midi filter: Types is a grid of toggles; Notes and CCs are rows of ranges. - Midi transform and Midi value shaper output the value of each message they produce. - Midi Glide: Priority, On release, Transition (retrigger or legato) and Latch, with a Hold button. - Midi sustain: Pedal is a momentary button. - Midi CC snapshot is removed: cues do this. - halp::toggle_grid: a grid of named toggles. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qr9WkcUjEGCueg7eG1KxSr --- .../Advanced/MidiScaler/MidiCCSnapshot.hpp | 244 -------------- .../MidiScaler/MidiCommon/Messages.hpp | 38 +++ examples/Advanced/MidiScaler/MidiFilter.hpp | 194 ++++++++--- examples/Advanced/MidiScaler/MidiSustain.hpp | 6 +- .../Advanced/MidiScaler/MidiTransform.hpp | 28 +- .../Advanced/MidiScaler/MidiValueShaper.hpp | 39 ++- examples/Midi/MidiGlide.hpp | 312 ++++++++++++++++-- include/avnd/wrappers/widgets.hpp | 8 + include/halp/toggle_grid.hpp | 49 +++ 9 files changed, 594 insertions(+), 324 deletions(-) delete mode 100644 examples/Advanced/MidiScaler/MidiCCSnapshot.hpp create mode 100644 include/halp/toggle_grid.hpp diff --git a/examples/Advanced/MidiScaler/MidiCCSnapshot.hpp b/examples/Advanced/MidiScaler/MidiCCSnapshot.hpp deleted file mode 100644 index 6c8f2008..00000000 --- a/examples/Advanced/MidiScaler/MidiCCSnapshot.hpp +++ /dev/null @@ -1,244 +0,0 @@ -#pragma once - -/* SPDX-License-Identifier: GPL-3.0-or-later */ - -#include - -#include -#include -#include -#include -#include -#include - -#include -#include - -namespace mtk -{ -/** - * @brief Up to 8 stored controller values on one channel, sent on demand. - * - * Incoming MIDI passes through. In Takeover mode, once a slot has been sent, - * incoming values of the same controller on the same channel are held back - * until they reach or cross the stored value, so that moving a physical knob - * does not make the parameter jump away from the recalled value. - */ -struct MidiCCSnapshot -{ - halp_meta(name, "Midi CC snapshot") - halp_meta(c_name, "midi_cc_snapshot") - halp_meta(category, "Midi/Control") - halp_meta(author, "ossia score") - halp_meta(description, "Send up to 8 stored controller values on a channel") - halp_meta(manual_url, "https://ossia.io/score-docs/processes/midi-cc-snapshot.html") - halp_meta(uuid, "e941cff0-8ebb-4259-a146-29ce10125791") - halp_flag(process_exec); - - static constexpr int slots = 8; - - enum Mode - { - Immediate, - Takeover - }; - - template - struct cc_number : halp::spinbox_i32 - { - halp_meta(description, "Controller number of this slot") - }; - template - struct cc_value : halp::hslider_i32 - { - halp_meta(description, "Value sent for this slot") - }; - - struct ins - { - halp::midi_bus<"MIDI", libremidi::message> midi; - struct : halp::impulse_button<"Send"> - { - halp_meta(description, "Send every active slot") - } send; - struct : halp::toggle<"Send on change"> - { - halp_meta(description, "Also send a slot whenever its number or value changes") - } on_change; - struct : halp::enum_t - { - halp_meta( - description, - "Takeover: after a slot is sent, incoming values of that controller are " - "held back until they reach the stored value") - } mode; - struct : halp::spinbox_i32<"Channel", halp::range{1, 16, 1}> - { - halp_meta(description, "Channel of the controllers sent") - } channel; - struct : halp::spinbox_i32<"Slots", halp::range{1, slots, slots}> - { - halp_meta(description, "Number of active slots") - } count; - - cc_number<"CC 1", 20> cc1; - cc_value<"Value 1"> value1; - cc_number<"CC 2", 21> cc2; - cc_value<"Value 2"> value2; - cc_number<"CC 3", 22> cc3; - cc_value<"Value 3"> value3; - cc_number<"CC 4", 23> cc4; - cc_value<"Value 4"> value4; - cc_number<"CC 5", 24> cc5; - cc_value<"Value 5"> value5; - cc_number<"CC 6", 25> cc6; - cc_value<"Value 6"> value6; - cc_number<"CC 7", 26> cc7; - cc_value<"Value 7"> value7; - cc_number<"CC 8", 27> cc8; - cc_value<"Value 8"> value8; - } inputs; - - struct - { - halp::midi_bus<"MIDI", libremidi::message> midi; - } outputs; - - struct ui - { - halp_meta(name, "Midi CC snapshot") - halp_meta(layout, halp::layouts::hbox) - halp_meta(background, halp::colors::background_dark) - - struct - { - halp_meta(name, "Send") - halp_meta(layout, halp::layouts::section) - halp_meta(background, halp::colors::background_mid) - halp::item<&ins::send> send; - halp::item<&ins::on_change> on_change; - halp::item<&ins::mode> mode; - struct - { - halp_meta(layout, halp::layouts::hbox) - halp::item<&ins::channel> channel; - halp::item<&ins::count> count; - } numbers; - } send; - - struct - { - halp_meta(name, "Slots") - halp_meta(layout, halp::layouts::section) - halp_meta(background, halp::colors::background_mid) - struct - { - halp_meta(layout, halp::layouts::grid) - halp_meta(columns, 4) - halp::item<&ins::cc1> cc1; - halp::item<&ins::value1> value1; - halp::item<&ins::cc2> cc2; - halp::item<&ins::value2> value2; - halp::item<&ins::cc3> cc3; - halp::item<&ins::value3> value3; - halp::item<&ins::cc4> cc4; - halp::item<&ins::value4> value4; - halp::item<&ins::cc5> cc5; - halp::item<&ins::value5> value5; - halp::item<&ins::cc6> cc6; - halp::item<&ins::value6> value6; - halp::item<&ins::cc7> cc7; - halp::item<&ins::value7> value7; - halp::item<&ins::cc8> cc8; - halp::item<&ins::value8> value8; - } grid; - } slot_grid; - }; - - void stop() noexcept { m_stop = true; } - - using tick = halp::tick_musical; - void operator()(halp::tick_musical) noexcept - { - if(m_stop) - { - m_stop = false; - m_locked = {}; - } - - const std::array ccs{inputs.cc1, inputs.cc2, inputs.cc3, - inputs.cc4, inputs.cc5, inputs.cc6, - inputs.cc7, inputs.cc8}; - const std::array values{inputs.value1, inputs.value2, inputs.value3, - inputs.value4, inputs.value5, inputs.value6, - inputs.value7, inputs.value8}; - const int count = std::clamp(int(inputs.count), 1, slots); - const int channel = std::clamp(int(inputs.channel), 1, 16); - - for(int i = 0; i < slots; i++) - { - const bool changed = m_initialized - && (ccs[i] != m_last_cc[i] || values[i] != m_last_value[i] - || channel != m_last_channel); - if(i < count && (inputs.send || (inputs.on_change && changed))) - send_slot(i, channel, ccs[i], values[i]); - m_last_cc[i] = ccs[i]; - m_last_value[i] = values[i]; - } - m_last_channel = channel; - m_initialized = true; - - for(const auto& m : inputs.midi) - { - if(pass(m, channel, count, ccs, values)) - outputs.midi.push_back(m); - } - } - -private: - std::array m_last_cc{}, m_last_value{}; - int m_last_channel{}; - //! In Takeover mode, slots whose incoming controller is held back. - std::array m_locked{}; - //! Side of the stored value the last held-back value was on: -1, 0 or 1. - std::array m_side{}; - bool m_initialized{}; - bool m_stop{}; - - void send_slot(int i, int channel, int cc, int value) - { - auto m = libremidi::channel_events::control_change( - channel, std::clamp(cc, 0, 127), std::clamp(value, 0, 127)); - m.timestamp = 0; - outputs.midi.push_back(m); - m_locked[i] = inputs.mode == Takeover; - m_side[i] = 0; - } - - bool pass( - const libremidi::message& m, int channel, int count, - const std::array& ccs, const std::array& values) noexcept - { - if(inputs.mode != Takeover || m.size() < 3 || (m.bytes[0] & 0xF0) != 0xB0 - || channel_of(m) != channel) - return true; - - bool keep = true; - for(int i = 0; i < count; i++) - { - if(!m_locked[i] || ccs[i] != m.bytes[1]) - continue; - const int v = m.bytes[2]; - const int side = v < values[i] ? -1 : (v > values[i] ? 1 : 0); - if(side == 0 || (m_side[i] != 0 && side != m_side[i])) - { - m_locked[i] = false; - continue; - } - m_side[i] = int8_t(side); - keep = false; - } - return keep; - } -}; -} diff --git a/examples/Advanced/MidiScaler/MidiCommon/Messages.hpp b/examples/Advanced/MidiScaler/MidiCommon/Messages.hpp index 7ac5d9d2..6c26d675 100644 --- a/examples/Advanced/MidiScaler/MidiCommon/Messages.hpp +++ b/examples/Advanced/MidiScaler/MidiCommon/Messages.hpp @@ -133,6 +133,44 @@ inline void set_channel(libremidi::message& m, int channel) noexcept 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 { diff --git a/examples/Advanced/MidiScaler/MidiFilter.hpp b/examples/Advanced/MidiScaler/MidiFilter.hpp index ea1f2c33..b7f04b4b 100644 --- a/examples/Advanced/MidiScaler/MidiFilter.hpp +++ b/examples/Advanced/MidiScaler/MidiFilter.hpp @@ -14,12 +14,14 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include #include @@ -28,7 +30,8 @@ namespace mtk { /** - * @brief A set of message kinds written as text: "note, cc, bend, -sysex". + * @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 @@ -153,29 +156,45 @@ struct kind_set } }; -//! Keeps a kind_set in sync with a text control without allocating. -struct cached_kind_set +//! An inclusive range of data byte values, a row of a range list control. +struct byte_range { - static constexpr std::size_t max_text = 256; - kind_set set; + 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{}; - const kind_set& update(std::string_view text) noexcept + bool empty() const noexcept { return count == 0; } + + void assign(const auto& list, int min, int max) 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 set; - std::copy(text.begin(), text.end(), m_text.begin()); - m_size = text.size(); - m_parsed = true; - set.parse(text); - return set; + 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)}; + } } -private: - std::array m_text{}; - std::size_t m_size{}; - bool m_parsed{}; + 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]. @@ -235,6 +254,46 @@ struct range_maps } }; +/** + * @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. @@ -242,9 +301,10 @@ struct range_maps * Messages that are not kept go to the "Rejected" output, so the object also * splits a keyboard or a set of channels. * - * Text lists ("Notes", "CCs", "Value range") are comma-separated inclusive - * ranges such as "1-4, 6"; notes accept names such as "C-1-B2, C4". An empty - * list, as every channel on in "Channels", does not restrict anything. + * "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. @@ -309,7 +369,8 @@ struct MidiFilter { halp_meta( description, - "Kind of message kept when Kinds is empty, and kind of the value outputs") + "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}> { @@ -323,27 +384,64 @@ struct MidiFilter halp::toggle<"Note off to zero"> note_off_to_zero; halp::toggle<"Zero to note off"> zero_to_note_off; - struct : halp::lineedit<"Types", ""> + //! 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, - "Message types kept, e.g. note, cc, program, bend, pressure, poly, sysex, mtc, " - "spp, song, tune, clock, start, continue, stop, sensing, reset, or the " - "groups all, channel, system, realtime, transport. A leading '-' " - "removes. Empty: Filter type decides.") + "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 : halp::lineedit<"Notes", ""> + struct notes_list : range_rows<"Notes", true> { halp_meta( - description, "Notes kept, by number or name, e.g. C-1-B2, 60. Empty: all.") + description, + "Notes kept, as ranges from one note number to another (60 is C4). No " + "rows: all.") } notes; - struct : halp::lineedit<"CCs", ""> + struct ccs_list : range_rows<"CCs", false> { - halp_meta(description, "Controller numbers kept, e.g. 1, 7, 64-69. Empty: all.") + halp_meta( + description, + "Controller numbers kept, as ranges from one number to another. No rows: " + "all.") } ccs; struct : halp::toggle<"Invert ranges"> { @@ -539,9 +637,10 @@ struct MidiFilter uint8_t count{}; }; - // Settings parsed from the text controls - cached_kind_set m_kinds; - cached_range_list m_notes, m_ccs, m_values; + // 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. @@ -550,9 +649,9 @@ struct MidiFilter void update_settings() noexcept { - m_kinds.update(inputs.kinds.value); - m_notes.update(inputs.notes.value, 0, 127, true); - m_ccs.update(inputs.ccs.value, 0, 127); + 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); @@ -630,11 +729,16 @@ struct MidiFilter return e.to_main || (e.pass && kind_accepts(off, midi_kind::note_off)); } - //! Kind selection: the Kinds list, or the legacy Filter type when it is empty. + //! 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.set.empty) - return m_kinds.set.contains(k); + 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(); @@ -687,15 +791,15 @@ struct MidiFilter tested = true; inside = inside && ((mask >> (ch - 1)) & 1); } - if(is_note_kind(k) && !m_notes.list.empty() && m.size() >= 2) + if(is_note_kind(k) && !m_notes.empty() && m.size() >= 2) { tested = true; - inside = inside && m_notes.list.contains(m.bytes[1]); + inside = inside && m_notes.contains(m.bytes[1]); } - if(k == midi_kind::control_change && !m_ccs.list.empty() && m.size() >= 2) + if(k == midi_kind::control_change && !m_ccs.empty() && m.size() >= 2) { tested = true; - inside = inside && m_ccs.list.contains(m.bytes[1]); + inside = inside && m_ccs.contains(m.bytes[1]); } if(tested && inside == bool(inputs.drop_in_range)) return false; diff --git a/examples/Advanced/MidiScaler/MidiSustain.hpp b/examples/Advanced/MidiScaler/MidiSustain.hpp index dcab0b6e..75c5b95e 100644 --- a/examples/Advanced/MidiScaler/MidiSustain.hpp +++ b/examples/Advanced/MidiScaler/MidiSustain.hpp @@ -70,9 +70,11 @@ struct MidiSustain }; } mode; - struct : halp::toggle<"Pedal"> + struct : halp::maintained_button<"Pedal"> { - halp_meta(description, "Holds the pedal down on every channel, as CC 64 does on one") + 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}> diff --git a/examples/Advanced/MidiScaler/MidiTransform.hpp b/examples/Advanced/MidiScaler/MidiTransform.hpp index 0aae08fd..db6f1c23 100644 --- a/examples/Advanced/MidiScaler/MidiTransform.hpp +++ b/examples/Advanced/MidiScaler/MidiTransform.hpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -40,6 +41,11 @@ namespace mtk * 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 { @@ -257,6 +263,14 @@ struct MidiTransform 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 @@ -390,6 +404,14 @@ struct MidiTransform 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; @@ -511,7 +533,7 @@ struct MidiTransform { const auto [ic, ip] = identity(in, k); while(m_tracker.end(ic, ip, in.timestamp, velocity, [this](libremidi::message&& m) { - emit(m); + emit_produced(m); })) ; } @@ -533,7 +555,7 @@ struct MidiTransform if(is_end(in, k) || b2 == 0) return; const auto [ic, ip] = identity(in, k); - auto emitter = [this](libremidi::message&& m) { emit(m); }; + 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; @@ -590,7 +612,7 @@ struct MidiTransform return; } out.timestamp = in.timestamp; - emit(out); + emit_produced(out); } bool into_is_realtime() const noexcept diff --git a/examples/Advanced/MidiScaler/MidiValueShaper.hpp b/examples/Advanced/MidiScaler/MidiValueShaper.hpp index 0327db99..a0e8fc56 100644 --- a/examples/Advanced/MidiScaler/MidiValueShaper.hpp +++ b/examples/Advanced/MidiScaler/MidiValueShaper.hpp @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -38,6 +39,10 @@ namespace mtk * 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 { @@ -195,6 +200,13 @@ struct MidiValueShaper 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. @@ -226,7 +238,10 @@ struct MidiValueShaper for(const auto& m : inputs.midi) { auto out = m; - process(out); + 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)); } } @@ -321,12 +336,13 @@ struct MidiValueShaper } private: - void process(libremidi::message& m) noexcept + //! Whether the message was of the kind shaped. + bool process(libremidi::message& m) noexcept { if(!is_channel_message(m)) - return; + return false; if(inputs.channel != 0 && channel_of(m) != inputs.channel) - return; + return false; const int number = inputs.number; const uint8_t status = m.bytes[0] & 0xF0; @@ -337,6 +353,7 @@ struct MidiValueShaper { 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; @@ -344,14 +361,20 @@ struct MidiValueShaper // 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: @@ -361,19 +384,27 @@ struct MidiValueShaper 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 diff --git a/examples/Midi/MidiGlide.hpp b/examples/Midi/MidiGlide.hpp index 3095b221..db3777e6 100644 --- a/examples/Midi/MidiGlide.hpp +++ b/examples/Midi/MidiGlide.hpp @@ -4,21 +4,39 @@ #include #include +#include #include #include #include +#include #include +#include namespace mo { /** - * Monophonic legato: one note sounds at a time. A new note-on ends the note - * that sounds, a note-off ends it only if it is its own key's. + * Monophonic: one note sounds at a time, chosen among the keys held as a + * monophonic synthesizer does. * - * 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. + * - 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 { @@ -26,15 +44,93 @@ struct MidiGlide halp_meta(c_name, "midi_glide") 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 @@ -42,6 +138,34 @@ struct MidiGlide halp::midi_bus<"out", libremidi::message> out; } outputs; + struct ui + { + halp_meta(name, "Midi Glide") + halp_meta(layout, halp::layouts::hbox) + halp_meta(background, halp::colors::background_mid) + struct + { + 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 + { + halp_meta(layout, halp::layouts::vbox) + static constexpr auto condition() + { + 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; } @@ -51,61 +175,197 @@ struct MidiGlide { 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 int channel = m.get_channel(); - const uint8_t pitch = m.bytes[1]; + 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) { - if(sounding() && pitch == m_note && channel == m_channel) - { - const bool is_off = m.get_message_type() == libremidi::message_type::NOTE_OFF; - end_current(is_off ? vel : 0, m.timestamp); - } + const bool is_off = m.get_message_type() == libremidi::message_type::NOTE_OFF; + key_up(k, is_off ? vel : 0, m.timestamp); } else { - end_current(0, m.timestamp); - outputs.out.push_back(m); - m_note = pitch; - m_channel = channel; + key_down(k, vel, m.timestamp); } } else { - // All sound off and all notes off end the note at the receiver. + // 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) && m.get_channel() == m_channel) - m_note = no_note; + && (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_note != no_note; } + 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_channel, m_note, velocity)); + outputs.out.push_back( + libremidi::channel_events::note_off(m_current.channel, m_current.pitch, velocity)); outputs.out.back().timestamp = ts; - m_note = no_note; + m_current.pitch = no_note; } - uint8_t m_note{no_note}; - int m_channel{1}; + std::array m_held{}; + int m_held_count{}; + key m_current{}; + bool m_latching{}; bool m_stop_requested{}; }; } diff --git a/include/avnd/wrappers/widgets.hpp b/include/avnd/wrappers/widgets.hpp index 0e561728..7613f845 100644 --- a/include/avnd/wrappers/widgets.hpp +++ b/include/avnd/wrappers/widgets.hpp @@ -36,6 +36,8 @@ enum class widget_type note_matrix, //! The 16 MIDI channels, see halp::channel_mask channel_mask, + //! Named toggles, see halp::toggle_grid + toggle_grid, choices, xy, xy_spinbox, @@ -101,6 +103,8 @@ struct widget_reflection 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: @@ -258,6 +262,10 @@ consteval auto get_widget() { 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}; diff --git a/include/halp/toggle_grid.hpp b/include/halp/toggle_grid.hpp new file mode 100644 index 00000000..20d13ea1 --- /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; + } +}; +} From ffaa548009b2ca5198d339bbf3bb0a49c1ed2c30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Tue, 27 Oct 2026 20:14:42 -0400 Subject: [PATCH 36/36] MidiScaler: MTS selects the tuning on every channel and can be sent again The tuning goes with the RPN selecting tuning program 0 on all 16 channels, which receivers such as FluidSynth need to apply it; Send tuning and playback start send it again. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qr9WkcUjEGCueg7eG1KxSr --- examples/Advanced/MidiScaler/MidiScaler.hpp | 75 ++++++++++++++++++++- 1 file changed, 72 insertions(+), 3 deletions(-) diff --git a/examples/Advanced/MidiScaler/MidiScaler.hpp b/examples/Advanced/MidiScaler/MidiScaler.hpp index d724d07a..ca5edae9 100644 --- a/examples/Advanced/MidiScaler/MidiScaler.hpp +++ b/examples/Advanced/MidiScaler/MidiScaler.hpp @@ -56,7 +56,9 @@ struct MidiScaler 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 whenever it changes.") + "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, @@ -179,6 +181,15 @@ struct MidiScaler "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 @@ -217,6 +228,16 @@ struct MidiScaler 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; }; @@ -277,9 +298,14 @@ struct MidiScaler 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) @@ -351,6 +377,47 @@ struct MidiScaler 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 { @@ -451,11 +518,12 @@ struct MidiScaler m_adjust = adjust; m_rescale = rescale; - if(mode == output_mode_control::mts_sysex && (retuned || !m_mts_sent)) + if(mode == output_mode_control::mts_sysex && (retuned || !m_mts_sent || m_send_tuning)) { - send_mts(0, emit); + send_tuning(0, emit); m_mts_sent = true; } + m_send_tuning = false; for(const auto& in : this->inputs.midi) { @@ -648,6 +716,7 @@ struct MidiScaler float m_rescale{1.f}; bool m_tuning_changed{true}; bool m_mts_sent{false}; + bool m_send_tuning{false}; bool m_stop{false}; }; }