Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
c239ed4
midi: shared helpers for the MIDI objects
jcelerier Oct 22, 2026
a701489
midi: tagged events in the event queue, note_tracker::find
jcelerier Oct 22, 2026
42484ac
midi: retuned MIDI output in Midi scaler, Midi dispatcher, Midi MPE
jcelerier Oct 22, 2026
a536065
midi: Midi sustain
jcelerier Oct 22, 2026
e3951d2
midi: Midi filter selects every kind, ranges and remaps, Rejected output
jcelerier Oct 22, 2026
80861bc
midi: Midi transform and Midi CC snapshot
jcelerier Oct 22, 2026
1e3cc0f
midi: MIDI Sync In passes the clock through a divider, shift and delay
jcelerier Oct 22, 2026
7fa8bfe
midi: Midi delay
jcelerier Oct 22, 2026
d90b535
midi: Midi value shaper
jcelerier Oct 22, 2026
a8119ad
midi: Midi Humanize chance per chord or period, euclid gate, random CC
jcelerier Oct 22, 2026
0865239
midi: grouped layouts and clearer labels for the MIDI objects
jcelerier Oct 23, 2026
698f2f6
midi: Midi filter note-offs follow their note-on, grouped layout
jcelerier Oct 23, 2026
d010107
midi: Midi dispatcher takes a voice over instead of dropping notes
jcelerier Oct 23, 2026
5317c4c
midi: Midi scaler reads incoming bend with its own range
jcelerier Oct 23, 2026
e78aa46
midi: MIDI Sync In fixes MTC 29.97 / 30, full frames, defaults
jcelerier Oct 23, 2026
689528e
midi: Roman-numeral progressions and Midi progression player
jcelerier Oct 23, 2026
6604173
midi: Midi random sequencer, Turing Machine and Marbles style
jcelerier Oct 23, 2026
4dd9007
midi: Midi life, a Game of Life sequencer
jcelerier Oct 23, 2026
ced5e28
halp: value lists, note matrices and channel masks
jcelerier Oct 23, 2026
8e438aa
midi: channel masks, map rows, byte ranges and note lists instead of …
jcelerier Oct 23, 2026
7ccc49b
midi: subcategories Sequencers, Generators, Pitch, Timing, Routing, C…
jcelerier Oct 23, 2026
371256b
midi: MIDI Sync In stays in Timing/Midi with the other sync objects
jcelerier Oct 23, 2026
b18c33a
midi: MIDI Sync In, a full MTC frame restarts the timecode decoding
jcelerier Oct 24, 2026
7daa778
midi: compact layouts, 0-1 proportions, stop on process end, review f…
jcelerier Oct 24, 2026
cd62bfd
halp: drag payloads and timeline clips for custom items
jcelerier Oct 24, 2026
b8d84ab
midi examples: review fixes
jcelerier Oct 24, 2026
5de9e18
ossia binding: MIDI outlets keep every run of a buffer
jcelerier Oct 24, 2026
6b7323a
halp: timeline_clip holds the files placed together in one box
jcelerier Oct 25, 2026
49d8904
MidiGlide: note-offs on the channel of their note, a note ended on stop
jcelerier Oct 25, 2026
826bab3
ossia binding: MIDI 1 inputs get sysex whole
jcelerier Oct 25, 2026
310e8f1
halp: an alignment for layouts
jcelerier Oct 25, 2026
1d074d1
MidiHumanizer: seed 0 plays a new take after each stop
jcelerier Oct 26, 2026
79bf341
halp: grid_chooser control and next_grid_point helper
jcelerier Oct 26, 2026
cf44092
halp: timeline_clip says how a clip follows the tempo
jcelerier Oct 26, 2026
e952e98
MIDI objects: filter by checkboxes and ranges, values out, Glide mode…
jcelerier Oct 27, 2026
ffaa548
MidiScaler: MTS selects the tuning on every channel and can be sent a…
jcelerier Oct 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions book/src/advanced/ui.layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
37 changes: 37 additions & 0 deletions book/src/advanced/ui.painting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<void(halp::timeline_clip)> 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:

Expand Down
30 changes: 17 additions & 13 deletions book/src/writing_processors/midi.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 63 additions & 0 deletions book/src/writing_processors/ports.metadatas.widget.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` (`<halp/grid_chooser.hpp>`) 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` (`<halp/grid.hpp>`) 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.
177 changes: 177 additions & 0 deletions examples/Advanced/MidiScaler/MidiCommon/ChannelAllocator.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
#pragma once

/* SPDX-License-Identifier: GPL-3.0-or-later */

#include <algorithm>
#include <array>
#include <cstdint>
#include <optional>

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 <typename Random>
std::optional<int> 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<int> allocate(int64_t now) noexcept
{
return allocate(now, [] { return 0.; });
}

//! Marks a channel as playing one more note, whether it was allocated here
//! or chosen by the caller (a surplus channel, say).
void take(int ch) noexcept
{
if(ch < 1 || ch > 16)
return;
m_ch[ch - 1].held++;
m_last = ch;
}

void release(int ch, int64_t now) noexcept
{
if(ch < 1 || ch > 16)
return;
auto& c = m_ch[ch - 1];
if(c.held > 0)
c.held--;
if(c.held == 0)
c.ended_at = now;
}

int held(int ch) const noexcept { return m_ch[(ch - 1) & 15].held; }

//! The usable channel with no note held whose last note ended first, even
//! if it is still in its release reserve; nothing if every one holds a note.
std::optional<int> oldest_released() const noexcept
{
std::optional<int> 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<int> nth_usable(int n) const noexcept
{
for(int ch = 1; ch <= 16; ch++)
if(usable(ch) && n-- == 0)
return ch;
return std::nullopt;
}

void reset() noexcept
{
m_ch = {};
m_last = 0;
m_dir = 1;
}

private:
//! The first candidate after `from` in direction `dir`; with `wrap`, starts
//! over from the other end. Returns 0 when there is none.
static int next_after(const int* cands, int n, int from, int dir, bool wrap) noexcept
{
if(dir > 0)
{
for(int i = 0; i < n; i++)
if(cands[i] > from)
return cands[i];
return wrap ? cands[0] : 0;
}
else
{
for(int i = n - 1; i >= 0; i--)
if(cands[i] < from)
return cands[i];
return wrap ? cands[n - 1] : 0;
}
}

struct channel_state
{
int held{};
int64_t ended_at{INT64_MIN / 2};
};
std::array<channel_state, 16> 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};
};
}
Loading
Loading