Vendor helgoboss/helgobox (ReaLearn) as basis for custom UI fork

Stripped upstream git history; starting point for replacing the native
SWELL/Win32 mapping UI with something more suited to bulk editing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Paul Lipscomb
2026-07-15 17:38:29 -04:00
parent 583ed77a67
commit e58f06d9fa
2232 changed files with 685575 additions and 1 deletions
@@ -0,0 +1,40 @@
[package]
name = "playtime-api"
version = "0.1.0"
authors = ["Benjamin Klum <benjamin.klum@helgoboss.org>"]
edition = "2021"
publish = false
[dependencies]
serde.workspace = true
# For reusing common sound-related types that conveniently happen to be compatible with REAPER
reaper-common-types.workspace = true
# For exposing a runtime API within REAPER
reaper-low.workspace = true
# For being able to use the API macro
helgobox-macros.workspace = true
# For generating random IDs
nanoid.workspace = true
# For easier Display impl
derive_more.workspace = true
# For encoding/decoding a signed matrix value
rmp-serde.workspace = true
# For encoding/decoding a signed matrix value
base64.workspace = true
# For proper error types
thiserror.workspace = true
# For date/time persistence
chrono = { workspace = true, features = ["serde"] }
# For better error handling
anyhow.workspace = true
# For capturing and reporting unknown properties
serde_json.workspace = true
# For primitive enums
strum.workspace = true
# For primitive enums
num_enum.workspace = true
# For UTF-8 paths
camino = { workspace = true, features = ["serde1"] }
[lints.clippy]
enum_glob_use = "deny"
@@ -0,0 +1,2 @@
pub mod persistence;
pub mod runtime;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,181 @@
use crate::persistence::{
MatrixSequenceColumnMessage, MatrixSequenceEvent, MatrixSequenceMessage,
MatrixSequenceRowMessage, MatrixSequenceSlotMessage, MatrixSequenceStartSlotMessage,
};
use serde::de::{Error, SeqAccess, Visitor};
use serde::ser::SerializeTuple;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::fmt::Formatter;
/// A very compact representation of an event.
///
/// The compactness is achieved using tuple serialization.
///
/// Pro:
///
/// - If used with a CSV serializer, the outcome can be made look as "noise-less" as
/// REAPER's MIDI sequence format (newline to separate events, space delimiter, unquoted strings).
/// - If used with JSON/Lua serializer, the outcome is valid JSON/Lua while still being compact.
/// No need for string embedding (which looks especially bad in JSON due to newline escaping).
/// - If used with a binary serializer (e.g. bincode or msgpack), one can achieve a *really* compact
/// serialization that also tops REAPER's MIDI sequence format. That will come in handy with
/// large undo histories or storage within RPP (in RPPs, we are base64-encoded, so the
/// human-readable-text advantage is not present anyway).
/// - TODO-high-ms3 Especially the last point could be desirable for MIDI sequences as well.
/// Use serde for them, too!
///
/// Contra:
///
/// - Not self-describing. However: If embedded in some `Serialize` wrapper that evaluates
/// `is_human_readable`, one could switch between this non-descriptive serialization style and a
/// (derived) descriptive serialization style. So we can have both if we want.
impl Serialize for MatrixSequenceEvent {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
use MatrixSequenceMessage as M;
match self.message {
M::PanicMatrix => {
let mut seq = serializer.serialize_tuple(2)?;
seq.serialize_element(&self.pulse_diff)?;
seq.serialize_element(&0u8)?;
seq.end()
}
M::StopMatrix => {
let mut seq = serializer.serialize_tuple(2)?;
seq.serialize_element(&self.pulse_diff)?;
seq.serialize_element(&1u8)?;
seq.end()
}
M::PanicColumn(m) => {
let mut seq = serializer.serialize_tuple(3)?;
seq.serialize_element(&self.pulse_diff)?;
seq.serialize_element(&2u8)?;
seq.serialize_element(&m.index)?;
seq.end()
}
M::StopColumn(m) => {
let mut seq = serializer.serialize_tuple(3)?;
seq.serialize_element(&self.pulse_diff)?;
seq.serialize_element(&3u8)?;
seq.serialize_element(&m.index)?;
seq.end()
}
M::StartScene(m) => {
let mut seq = serializer.serialize_tuple(3)?;
seq.serialize_element(&self.pulse_diff)?;
seq.serialize_element(&4u8)?;
seq.serialize_element(&m.index)?;
seq.end()
}
M::PanicSlot(m) => {
let mut seq = serializer.serialize_tuple(4)?;
seq.serialize_element(&self.pulse_diff)?;
seq.serialize_element(&5u8)?;
seq.serialize_element(&m.column_index)?;
seq.serialize_element(&m.row_index)?;
seq.end()
}
M::StartSlot(m) => {
let mut seq = serializer.serialize_tuple(5)?;
seq.serialize_element(&self.pulse_diff)?;
seq.serialize_element(&6u8)?;
seq.serialize_element(&m.column_index)?;
seq.serialize_element(&m.row_index)?;
seq.serialize_element(&m.velocity)?;
seq.end()
}
M::StopSlot(m) => {
let mut seq = serializer.serialize_tuple(4)?;
seq.serialize_element(&self.pulse_diff)?;
seq.serialize_element(&7u8)?;
seq.serialize_element(&m.column_index)?;
seq.serialize_element(&m.row_index)?;
seq.end()
}
}
}
}
struct MatrixSequenceEventVisitor;
impl<'de> Visitor<'de> for MatrixSequenceEventVisitor {
type Value = MatrixSequenceEvent;
fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result {
write!(formatter, "a tuple")
}
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
where
A: SeqAccess<'de>,
{
use MatrixSequenceMessage as M;
macro_rules! col(() => {
MatrixSequenceColumnMessage {
index: seq
.next_element()?
.ok_or(Error::custom("expected column index"))?
}
});
macro_rules! row(() => {
MatrixSequenceRowMessage {
index: seq
.next_element()?
.ok_or(Error::custom("expected row index"))?
}
});
macro_rules! slot(() => {
MatrixSequenceSlotMessage {
column_index: seq
.next_element()?
.ok_or(Error::custom("expected slot column index"))?,
row_index: seq
.next_element()?
.ok_or(Error::custom("expected slot row index"))?,
}
});
let pulse_diff: u32 = seq
.next_element()?
.ok_or(Error::custom("expected pulse diff"))?;
let msg_type: u8 = seq
.next_element()?
.ok_or(Error::custom("expected message type"))?;
let message = match msg_type {
0 => M::PanicMatrix,
1 => M::StopMatrix,
2 => M::PanicColumn(col!()),
3 => M::StopColumn(col!()),
4 => M::StartScene(row!()),
5 => M::PanicSlot(slot!()),
6 => M::StartSlot(MatrixSequenceStartSlotMessage {
column_index: seq
.next_element()?
.ok_or(Error::custom("expected slot column index"))?,
row_index: seq
.next_element()?
.ok_or(Error::custom("expected slot row index"))?,
// Full velocity by default
velocity: seq.next_element()?.unwrap_or(1.0),
}),
7 => M::StopSlot(slot!()),
_ => return Err(Error::custom(format!("unknown message type {msg_type}"))),
};
let event = MatrixSequenceEvent {
pulse_diff,
message,
};
Ok(event)
}
}
impl<'de> Deserialize<'de> for MatrixSequenceEvent {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_seq(MatrixSequenceEventVisitor)
}
}
@@ -0,0 +1,120 @@
//! Usually we use Protocol Buffers for the runtime app API but there are a few things that are
//! not performance-critical and better expressed in a Rust-first manner.
use crate::persistence::{ColumnAddress, RowAddress, SlotAddress};
use serde::{Deserialize, Serialize};
// We don't really need a tagged enum here but it's an easy way to transmit the event as a
// JSON object (vs. just a string) ... which is better for some clients. Plus, we might want
// to deliver some additional payloads in the future.
#[derive(Clone, Eq, PartialEq, Debug, Serialize, Deserialize)]
#[serde(tag = "kind")]
pub enum InfoEvent {
Generic(GenericInfoEvent),
RecordedMatrixSequence,
DiscardedMatrixSequenceBecauseEmpty,
RemovedMatrixSequence,
WroteMatrixSequenceToArrangement,
}
impl InfoEvent {
/// Creates an info event with a generic message. This is displayed as toast in the app.
pub fn generic(message: impl Into<String>) -> Self {
Self::Generic(GenericInfoEvent {
message: message.into(),
})
}
/// Creates an info event that would ideally be treated like a warning on the app side.
pub fn warning(message: impl Into<String>) -> Self {
// In the future, this could set a special error flag, so that it could be displayed
// in a different way in the app.
Self::generic(message)
}
}
#[derive(Clone, Eq, PartialEq, Debug, Serialize, Deserialize)]
pub struct GenericInfoEvent {
pub message: String,
}
#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)]
pub struct SimpleMappingContainer {
pub mappings: Vec<SimpleMapping>,
}
#[derive(Copy, Clone, PartialEq, Debug, Serialize, Deserialize)]
pub struct SimpleMapping {
pub source: SimpleSource,
pub target: SimpleMappingTarget,
}
#[derive(Copy, Clone, Eq, PartialEq, Debug, Serialize, Deserialize)]
#[serde(tag = "kind")]
pub enum SimpleSource {
Note(NoteSource),
MoreComplicated,
}
#[derive(Copy, Clone, Eq, PartialEq, Debug, Serialize, Deserialize)]
pub struct NoteSource {
pub channel: u8,
pub number: u8,
}
#[derive(Copy, Clone, Eq, PartialEq, Debug, Serialize, Deserialize)]
#[serde(tag = "kind")]
pub enum SimpleMappingTarget {
TriggerMatrix,
TriggerColumn(ColumnAddress),
TriggerRow(RowAddress),
TriggerSlot(SlotAddress),
SmartRecord,
EnterSilenceModeOrPlayIgnited,
SequencerRecordOnOffState,
SequencerPlayOnOffState,
TapTempo,
}
#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug, Default)]
pub struct CellAddress {
pub column_index: Option<usize>,
pub row_index: Option<usize>,
}
impl CellAddress {
pub fn new(column_index: Option<usize>, row_index: Option<usize>) -> Self {
Self {
column_index,
row_index,
}
}
pub fn matrix() -> Self {
Self::new(None, None)
}
pub fn column(column_index: usize) -> Self {
Self {
column_index: Some(column_index),
row_index: None,
}
}
pub fn row(row_index: usize) -> Self {
Self {
column_index: None,
row_index: Some(row_index),
}
}
pub fn slot(column_index: usize, row_index: usize) -> Self {
Self {
column_index: Some(column_index),
row_index: Some(row_index),
}
}
pub fn to_slot_address(&self) -> Option<SlotAddress> {
Some(SlotAddress::new(self.column_index?, self.row_index?))
}
}
@@ -0,0 +1,54 @@
use crate::persistence::SlotAddress;
use serde::Serialize;
#[derive(Clone, PartialEq, Debug, Default, Serialize)]
pub struct ControlUnitConfig {
#[serde(default)]
pub control_units: Vec<ControlUnit>,
}
#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug, Default, Serialize)]
pub struct ControlUnitId(u32);
impl ControlUnitId {
pub fn new(raw: u32) -> Self {
Self(raw)
}
pub fn get(&self) -> u32 {
self.0
}
}
/// A control unit represents a controller connected to Playtime.
///
/// While definitely a part of the Playtime domain, control units are **not** managed/persisted by
/// Playtime. That's the responsibility of the software that integrates Playtime and provides the
/// controller integration (in our case Helgobox with ReaLearn).
#[derive(Clone, PartialEq, Debug, Serialize)]
pub struct ControlUnit {
/// Uniquely identifies the control unit at runtime.
///
/// In our case (Helgobox/ReaLearn), it's equal to the ReaLearn unit ID.
pub id: ControlUnitId,
/// A display name which should indicate what connected device we are talking about.
pub name: String,
/// Color in which the control unit should be visualized in the Playtime matrix.
///
/// In our case (Helgobox/ReaLearn), the color is in most usage scenarios dictated by the
/// global controller definition but should also work without using the "global controller"
/// feature (by setting `custom_data.playtime.control_unit.palette_color` in the main
/// compartment data).
#[serde(skip_serializing_if = "Option::is_none")]
pub palette_color: Option<u32>,
/// The top-left column/row which this control unit controls.
///
/// This will change as the controller scrolls. So it must be changeable by ReaLearn's targets.
pub top_left_corner: SlotAddress,
/// Both column and row count are fixed.
///
/// It should be dictated by the ReaLearn main compartment as it's a decision of the main preset
/// which area of the controller's grid will be used for slot control.
pub column_count: u32,
pub row_count: u32,
}
@@ -0,0 +1,8 @@
mod reaper;
pub use reaper::*;
mod app;
pub use app::*;
mod control_unit;
pub use control_unit::*;
@@ -0,0 +1,23 @@
#![allow(non_snake_case)]
use helgobox_macros::reaper_api;
reaper_api![
PlaytimeApi, PlaytimeApiPointers, PlaytimeApiSession, register_playtime_api
{
/// Finds the first Helgobox instance in the given project that contains a Playtime matrix.
///
/// If the given project is `null`, it will look in the current project.
///
/// Returns the instance ID or -1 if none exists.
HB_FindFirstPlaytimeHelgoboxInstanceInProject(project: *mut reaper_low::raw::ReaProject) -> std::ffi::c_int;
/// Creates a new Playtime matrix in the given Helgobox instance.
HB_CreateClipMatrix(instance_id: std::ffi::c_int);
/// Shows or hides the app for the given Helgobox instance and makes sure that the app displays
/// Playtime.
///
/// If necessary, this will also start the app and create a Playtime matrix for the given instance.
HB_ShowOrHidePlaytime(instance_id: std::ffi::c_int);
}
];