Reorganize top-level directories with clearer naming convention

app -> app-desktop-macos, presets -> app-presets, server -> remote-server,
daw-config-reaper -> osc-config-daw, plugin-reaper-realearn -> plugin-reaper-relearn.
Updated run.py and presets.py path references accordingly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Paul Lipscomb
2026-07-15 17:51:05 -04:00
parent e58f06d9fa
commit 7ecc718f5d
2256 changed files with 11 additions and 5 deletions
@@ -0,0 +1,35 @@
use crate::base::notification;
use reaper_high::{Action, Reaper};
use reaper_medium::SectionId;
pub fn build_smart_command_name_from_action(action: &Action) -> Option<String> {
match action.command_name() {
// Built-in actions don't have a command name but a persistent command ID.
// Use command ID as string.
None => action.command_id().ok().map(|id| id.to_string()),
// ReaScripts and custom actions have a command name as persistent identifier.
Some(name) => Some(name.into_string()),
}
}
pub fn build_action_from_smart_command_name(
section_id: SectionId,
smart_command_name: &str,
) -> Option<Action> {
match smart_command_name.parse::<u32>() {
// Could parse this as command ID integer. This is a built-in action.
Ok(command_id_int) => match command_id_int.try_into() {
Ok(command_id) => Some(
Reaper::get()
.section_by_id(section_id)
.action_by_command_id(command_id),
),
Err(_) => {
notification::warn(format!("Invalid command ID {command_id_int}"));
None
}
},
// Couldn't parse this as integer. This is a ReaScript or custom action.
Err(_) => Some(Reaper::get().action_by_command_name(smart_command_name)),
}
}
@@ -0,0 +1,148 @@
use crate::application::{
ActivationType, Affected, BankConditionModel, Change, GetProcessingRelevance,
ModifierConditionModel, ProcessingRelevance,
};
use crate::domain::{
ActivationCondition, EelCondition, ExpressionCondition, ExpressionEvaluator, MappingId,
};
#[allow(clippy::enum_variant_names)]
pub enum ActivationConditionCommand {
SetActivationType(ActivationType),
SetModifierCondition1(ModifierConditionModel),
SetModifierCondition2(ModifierConditionModel),
SetBankCondition(BankConditionModel),
SetScript(String),
SetMappingId(Option<MappingId>),
}
#[derive(Eq, PartialEq)]
pub enum ActivationConditionProp {
ActivationType,
ModifierCondition1,
ModifierCondition2,
BankCondition,
Script,
MappingId,
}
impl GetProcessingRelevance for ActivationConditionProp {
fn processing_relevance(&self) -> Option<ProcessingRelevance> {
Some(ProcessingRelevance::ProcessingRelevant)
}
}
#[derive(Clone, Debug, Default)]
pub struct ActivationConditionModel {
activation_type: ActivationType,
modifier_condition_1: ModifierConditionModel,
modifier_condition_2: ModifierConditionModel,
bank_condition: BankConditionModel,
script: String,
mapping_id: Option<MappingId>,
}
impl Change<'_> for ActivationConditionModel {
type Command = ActivationConditionCommand;
type Prop = ActivationConditionProp;
fn change(
&mut self,
cmd: ActivationConditionCommand,
) -> Option<Affected<ActivationConditionProp>> {
use ActivationConditionCommand as C;
use ActivationConditionProp as P;
use Affected::*;
let affected = match cmd {
C::SetActivationType(v) => {
self.activation_type = v;
One(P::ActivationType)
}
C::SetModifierCondition1(v) => {
self.modifier_condition_1 = v;
One(P::ModifierCondition1)
}
C::SetModifierCondition2(v) => {
self.modifier_condition_2 = v;
One(P::ModifierCondition2)
}
C::SetBankCondition(v) => {
self.bank_condition = v;
One(P::BankCondition)
}
C::SetScript(v) => {
self.script = v;
One(P::Script)
}
C::SetMappingId(v) => {
self.mapping_id = v;
One(P::MappingId)
}
};
Some(affected)
}
}
impl ActivationConditionModel {
pub fn activation_type(&self) -> ActivationType {
self.activation_type
}
pub fn modifier_condition_1(&self) -> ModifierConditionModel {
self.modifier_condition_1
}
pub fn modifier_condition_2(&self) -> ModifierConditionModel {
self.modifier_condition_2
}
pub fn bank_condition(&self) -> BankConditionModel {
self.bank_condition
}
pub fn script(&self) -> &str {
&self.script
}
pub fn mapping_id(&self) -> Option<MappingId> {
self.mapping_id
}
pub fn create_activation_condition(&self) -> ActivationCondition {
use ActivationType::*;
match self.activation_type() {
Always => ActivationCondition::Always,
Modifiers => {
let conditions = self
.modifier_conditions()
.filter_map(|m| m.create_modifier_condition())
.collect();
ActivationCondition::Modifiers(conditions)
}
Bank => ActivationCondition::Program {
param_index: self.bank_condition().param_index(),
program_index: self.bank_condition().bank_index(),
},
Eel => match EelCondition::compile(self.script()) {
Ok(c) => ActivationCondition::Eel(Box::new(c)),
Err(_) => ActivationCondition::Always,
},
Expression => match ExpressionCondition::compile(self.script()) {
Ok(e) => ActivationCondition::Expression(Box::new(e)),
Err(_) => ActivationCondition::Always,
},
TargetValue => match ExpressionEvaluator::compile(self.script()) {
Ok(e) => ActivationCondition::TargetValue {
lead_mapping: self.mapping_id,
condition: Box::new(e),
},
Err(_) => ActivationCondition::Always,
},
}
}
fn modifier_conditions(&self) -> impl Iterator<Item = ModifierConditionModel> {
use std::iter::once;
once(self.modifier_condition_1()).chain(once(self.modifier_condition_2()))
}
}
@@ -0,0 +1,61 @@
use crate::domain::{ControlInput, DeviceControlInput, DeviceFeedbackOutput, FeedbackOutput};
use strum::EnumIs;
/// Data about an automatically loaded unit.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct AutoUnitData {
pub controller_id: String,
pub controller_palette_color: Option<u32>,
pub input: Option<DeviceControlInput>,
pub output: Option<DeviceFeedbackOutput>,
pub controller_preset_usage: Option<ControllerPresetUsage>,
pub main_preset_id: String,
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct ControllerPresetUsage {
pub controller_preset_id: String,
/// `None` means that the default controller preset has been taken as a last resort even
/// though it couldn't be verified that it's suitable.
pub main_preset_suitability: Option<MainPresetSuitability>,
/// `None` means that the default controller preset has been taken as a last resort even
/// though it couldn't be verified that it's suitable.
pub controller_suitability: Option<ControllerSuitability>,
}
/// Suitability of a controller preset for a main preset.
#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Debug)]
pub struct MainPresetSuitability(u8);
impl MainPresetSuitability {
pub fn new(raw: u8) -> Self {
Self(raw)
}
pub fn get(&self) -> u8 {
self.0
}
pub fn is_generally_suitable(&self) -> bool {
self.0 > 0
}
}
/// Suitability of a controller preset for a connected controller.
#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Debug, EnumIs)]
pub enum ControllerSuitability {
NotSuitable = 0,
MaybeSuitable = 1,
Suitable = 2,
}
impl AutoUnitData {
pub fn control_input(&self) -> ControlInput {
self.input
.map(ControlInput::from_device_input)
.unwrap_or_default()
}
pub fn feedback_output(&self) -> Option<FeedbackOutput> {
self.output.map(FeedbackOutput::from_device_output)
}
}
@@ -0,0 +1,29 @@
use crate::application::{
Affected, GroupModel, GroupProp, MappingCommand, MappingModel, MappingProp,
};
use crate::domain::{CompartmentParamIndex, GroupId, MappingId, ParamSetting};
use base::hash_util::NonCryptoHashMap;
#[derive(Clone, Debug)]
pub struct CompartmentModel {
pub parameters: Vec<(CompartmentParamIndex, ParamSetting)>,
pub default_group: GroupModel,
pub groups: Vec<GroupModel>,
pub mappings: Vec<MappingModel>,
pub common_lua: String,
pub custom_data: NonCryptoHashMap<String, serde_json::Value>,
pub notes: String,
}
pub enum CompartmentCommand {
SetNotes(String),
SetCommonLua(String),
ChangeMapping(MappingId, Box<MappingCommand>),
}
pub enum CompartmentProp {
Notes,
CommonLua,
InGroup(GroupId, Affected<GroupProp>),
InMapping(MappingId, Affected<MappingProp>),
}
@@ -0,0 +1,61 @@
use crate::application::CompartmentModel;
use crate::domain::CompartmentKind;
use std::fmt;
pub trait CompartmentPresetManager: fmt::Debug {
fn find_by_id(&self, id: &str) -> Option<CompartmentPresetModel>;
}
#[derive(Clone, Debug)]
pub struct CompartmentPresetModel {
id: String,
name: String,
compartment: CompartmentKind,
model: CompartmentModel,
}
impl CompartmentPresetModel {
pub fn new(
id: String,
name: String,
compartment: CompartmentKind,
model: CompartmentModel,
) -> CompartmentPresetModel {
CompartmentPresetModel {
id,
name,
compartment,
model,
}
}
pub fn id(&self) -> &str {
&self.id
}
pub fn name(&self) -> &str {
&self.name
}
pub fn compartment(&self) -> CompartmentKind {
self.compartment
}
pub fn model(&self) -> &CompartmentModel {
&self.model
}
pub fn set_model(&mut self, data: CompartmentModel) {
self.model = data;
}
pub fn patch_custom_data(&mut self, key: String, value: serde_json::Value) {
self.model.custom_data.insert(key, value);
}
}
impl fmt::Display for CompartmentPresetModel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.name())
}
}
@@ -0,0 +1,111 @@
use crate::domain::{CompartmentParamIndex, ModifierCondition};
use derive_more::Display;
use num_enum::{IntoPrimitive, TryFromPrimitive};
use serde::{Deserialize, Serialize};
use strum::EnumIter;
#[derive(
Copy,
Clone,
Eq,
PartialEq,
Debug,
Default,
Serialize,
Deserialize,
EnumIter,
TryFromPrimitive,
IntoPrimitive,
Display,
)]
#[repr(usize)]
pub enum ActivationType {
#[default]
#[serde(rename = "always")]
#[display(fmt = "Always")]
Always,
#[serde(rename = "modifiers")]
#[display(fmt = "When modifiers on/off")]
Modifiers,
#[serde(rename = "program")]
#[display(fmt = "When bank selected")]
Bank,
#[serde(rename = "eel")]
#[display(fmt = "When EEL met")]
Eel,
#[serde(rename = "expression")]
#[display(fmt = "When expression met")]
Expression,
#[serde(rename = "target-value")]
#[display(fmt = "When target value met")]
TargetValue,
}
#[derive(Copy, Clone, Eq, PartialEq, Debug, Serialize, Deserialize, Default)]
pub struct ModifierConditionModel {
#[serde(rename = "paramIndex")]
pub param_index: Option<CompartmentParamIndex>,
#[serde(rename = "isOn")]
pub is_on: bool,
}
impl ModifierConditionModel {
pub fn create_modifier_condition(&self) -> Option<ModifierCondition> {
self.param_index
.map(|i| ModifierCondition::new(i, self.is_on))
}
pub fn param_index(&self) -> Option<CompartmentParamIndex> {
self.param_index
}
pub fn with_param_index(
&self,
param_index: Option<CompartmentParamIndex>,
) -> ModifierConditionModel {
ModifierConditionModel {
param_index,
..*self
}
}
pub fn is_on(&self) -> bool {
self.is_on
}
pub fn with_is_on(&self, is_on: bool) -> ModifierConditionModel {
ModifierConditionModel { is_on, ..*self }
}
}
#[derive(Copy, Clone, Eq, PartialEq, Debug, Serialize, Deserialize, Default)]
pub struct BankConditionModel {
#[serde(rename = "paramIndex")]
pub param_index: CompartmentParamIndex,
#[serde(rename = "programIndex")]
pub bank_index: u32,
}
impl BankConditionModel {
pub fn param_index(&self) -> CompartmentParamIndex {
self.param_index
}
pub fn with_param_index(&self, param_index: CompartmentParamIndex) -> BankConditionModel {
BankConditionModel {
param_index,
..*self
}
}
pub fn bank_index(&self) -> u32 {
self.bank_index
}
pub fn with_bank_index(&self, bank_index: u32) -> BankConditionModel {
BankConditionModel {
bank_index,
..*self
}
}
}
@@ -0,0 +1,200 @@
use crate::application::{
ActivationConditionCommand, ActivationConditionModel, ActivationConditionProp, Affected,
Change, GetProcessingRelevance, GroupData, ProcessingRelevance,
};
use crate::domain::{CompartmentKind, GroupId, GroupKey, Tag};
use core::fmt;
use std::cell::RefCell;
use std::rc::{Rc, Weak};
pub enum GroupCommand {
SetName(String),
SetTags(Vec<Tag>),
SetControlIsEnabled(bool),
SetFeedbackIsEnabled(bool),
ChangeActivationCondition(ActivationConditionCommand),
}
pub enum GroupProp {
Name,
Tags,
ControlIsEnabled,
FeedbackIsEnabled,
InActivationCondition(Affected<ActivationConditionProp>),
}
impl GetProcessingRelevance for GroupProp {
fn processing_relevance(&self) -> Option<ProcessingRelevance> {
use GroupProp as P;
match self {
P::Tags | P::ControlIsEnabled | P::FeedbackIsEnabled => {
Some(ProcessingRelevance::ProcessingRelevant)
}
P::InActivationCondition(p) => p.processing_relevance(),
P::Name => None,
}
}
}
/// A mapping group.
#[derive(Clone, Debug)]
pub struct GroupModel {
compartment: CompartmentKind,
id: GroupId,
key: GroupKey,
name: String,
tags: Vec<Tag>,
control_is_enabled: bool,
feedback_is_enabled: bool,
pub activation_condition_model: ActivationConditionModel,
}
impl Change<'_> for GroupModel {
type Command = GroupCommand;
type Prop = GroupProp;
fn change(&mut self, cmd: GroupCommand) -> Option<Affected<GroupProp>> {
use Affected::*;
use GroupCommand as C;
use GroupProp as P;
let affected = match cmd {
C::SetName(v) => {
self.name = v;
One(P::Name)
}
C::SetTags(v) => {
self.tags = v;
One(P::Tags)
}
C::SetControlIsEnabled(v) => {
self.control_is_enabled = v;
One(P::ControlIsEnabled)
}
C::SetFeedbackIsEnabled(v) => {
self.feedback_is_enabled = v;
One(P::FeedbackIsEnabled)
}
C::ChangeActivationCondition(cmd) => {
return self
.activation_condition_model
.change(cmd)
.map(|affected| One(P::InActivationCondition(affected)));
}
};
Some(affected)
}
}
impl GroupModel {
pub fn effective_name(&self) -> &str {
if self.is_default_group() {
"<Default>"
} else {
self.name()
}
}
pub fn name(&self) -> &str {
&self.name
}
pub fn tags(&self) -> &[Tag] {
&self.tags
}
pub fn control_is_enabled(&self) -> bool {
self.control_is_enabled
}
pub fn feedback_is_enabled(&self) -> bool {
self.feedback_is_enabled
}
pub fn activation_condition_model(&self) -> &ActivationConditionModel {
&self.activation_condition_model
}
}
impl fmt::Display for GroupModel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.effective_name())
}
}
/// See MappingModel for explanation.
impl PartialEq for GroupModel {
fn eq(&self, other: &Self) -> bool {
std::ptr::eq(self as _, other as _)
}
}
pub type SharedGroup = Rc<RefCell<GroupModel>>;
pub type WeakGroup = Weak<RefCell<GroupModel>>;
pub fn share_group(group: GroupModel) -> SharedGroup {
Rc::new(RefCell::new(group))
}
impl GroupModel {
pub fn new_from_ui(compartment: CompartmentKind, name: String) -> Self {
Self::new_internal(compartment, GroupId::random(), GroupKey::random(), name)
}
pub fn new_from_data(compartment: CompartmentKind, id: GroupId, key: GroupKey) -> Self {
Self::new_internal(compartment, id, key, "".to_string())
}
pub fn default_for_compartment(compartment: CompartmentKind) -> Self {
Self {
compartment,
id: GroupId::default(),
key: GroupKey::default(),
name: Default::default(),
tags: Default::default(),
control_is_enabled: true,
feedback_is_enabled: true,
activation_condition_model: ActivationConditionModel::default(),
}
}
fn new_internal(
compartment: CompartmentKind,
id: GroupId,
key: GroupKey,
name: String,
) -> Self {
Self {
id,
key,
name,
..Self::default_for_compartment(compartment)
}
}
pub fn compartment(&self) -> CompartmentKind {
self.compartment
}
pub fn id(&self) -> GroupId {
self.id
}
pub fn key(&self) -> &GroupKey {
&self.key
}
pub fn is_default_group(&self) -> bool {
self.id.is_default()
}
pub fn create_data(&self) -> GroupData {
GroupData {
control_is_enabled: self.control_is_enabled(),
feedback_is_enabled: self.feedback_is_enabled(),
activation_condition: self
.activation_condition_model
.create_activation_condition(),
tags: self.tags.clone(),
}
}
}
@@ -0,0 +1,85 @@
use crate::application::{Affected, ChangeResult};
use crate::domain::{SharedInstance, Tag};
use anyhow::Context;
use base::spawn_in_main_thread;
use derivative::Derivative;
use std::cell::RefCell;
use std::rc::{Rc, Weak};
pub type SharedInstanceModel = Rc<RefCell<InstanceModel>>;
pub type WeakInstanceModel = Weak<RefCell<InstanceModel>>;
#[derive(Derivative)]
#[derivative(Debug)]
pub struct InstanceModel {
tags: Vec<Tag>,
instance: SharedInstance,
#[derivative(Debug = "ignore")]
ui: Box<dyn InstanceUi>,
}
impl InstanceModel {
pub fn new(instance: SharedInstance, ui: Box<dyn InstanceUi>) -> Self {
Self {
tags: Default::default(),
instance,
ui,
}
}
pub fn instance(&self) -> &SharedInstance {
&self.instance
}
/// Returns all instance tags.
pub fn tags(&self) -> &[Tag] {
&self.tags
}
/// Modifies this instance by executing an instance command and returns the affected properties.
///
/// Doesn't invoke listeners.
pub fn change(&mut self, cmd: InstanceCommand) -> ChangeResult<InstanceProp> {
let affected = match cmd {
InstanceCommand::SetTags(tags) => {
self.tags = tags;
Some(Affected::One(InstanceProp::Tags))
}
};
Ok(affected)
}
/// Modifies this instance by executing an instance command and returns the affected properties.
///
/// Invokes listeners.
pub fn change_with_notification(
&mut self,
cmd: InstanceCommand,
initiator: Option<u32>,
weak_model: WeakInstanceModel,
) {
if let Ok(Some(affected)) = self.change(cmd) {
spawn_in_main_thread(async move {
let model = weak_model.upgrade().context("upgrading model")?;
model.borrow().ui.handle_affected(affected, initiator)?;
Ok(())
});
}
}
}
pub trait InstanceUi {
fn handle_affected(
&self,
affected: Affected<InstanceProp>,
initiator: Option<u32>,
) -> anyhow::Result<()>;
}
pub enum InstanceCommand {
SetTags(Vec<Tag>),
}
pub enum InstanceProp {
Tags,
}
@@ -0,0 +1,98 @@
use crate::domain::{
parse_hex_string, DisplayAsPrettyHex, LifecycleMidiData, LifecycleMidiMessage, MappingExtension,
};
use helgoboss_learn::RawMidiEvent;
use serde::{Deserialize, Serialize};
use serde_with::SerializeDisplay;
use std::convert::TryFrom;
use std::fmt::{Display, Formatter};
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct MappingExtensionModel {
pub on_activate: LifecycleModel,
pub on_deactivate: LifecycleModel,
}
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct LifecycleModel {
pub send_midi_feedback: Vec<LifecycleMidiMessageModel>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LifecycleMidiMessageModel {
Raw(RawMidiMessage),
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(untagged)]
pub enum RawMidiMessage {
HexString(RawHexStringMidiMessage),
ByteArray(RawByteArrayMidiMessage),
}
impl RawMidiMessage {
fn bytes(&self) -> &[u8] {
use RawMidiMessage::*;
match self {
HexString(msg) => &msg.0,
ByteArray(msg) => &msg.0,
}
}
}
#[derive(Clone, Debug, SerializeDisplay, Deserialize)]
#[serde(try_from = "String")]
pub struct RawHexStringMidiMessage(pub Vec<u8>);
impl Display for RawHexStringMidiMessage {
fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
DisplayAsPrettyHex(&self.0).fmt(f)
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RawByteArrayMidiMessage(pub Vec<u8>);
impl TryFrom<String> for RawHexStringMidiMessage {
type Error = hex::FromHexError;
fn try_from(value: String) -> Result<Self, Self::Error> {
let vec = parse_hex_string(&value)?;
Ok(Self(vec))
}
}
impl LifecycleMidiMessageModel {
pub fn create_lifecycle_midi_message(&self) -> Result<LifecycleMidiMessage, &'static str> {
use LifecycleMidiMessageModel::*;
let message = match self {
Raw(msg) => {
let event = RawMidiEvent::try_from_slice(0, msg.bytes())?;
LifecycleMidiMessage::Raw(Box::new(event))
}
};
Ok(message)
}
}
impl MappingExtensionModel {
pub fn create_mapping_extension(&self) -> Result<MappingExtension, &'static str> {
fn convert_messages(
model: &[LifecycleMidiMessageModel],
) -> Result<Vec<LifecycleMidiMessage>, &'static str> {
model
.iter()
.map(|m| m.create_lifecycle_midi_message())
.collect()
}
let ext = MappingExtension::new(LifecycleMidiData {
activation_midi_messages: convert_messages(&self.on_activate.send_midi_feedback)?,
deactivation_midi_messages: convert_messages(&self.on_deactivate.send_midi_feedback)?,
});
Ok(ext)
}
}
@@ -0,0 +1,790 @@
use crate::application::{
merge_affected, ActivationConditionCommand, ActivationConditionModel, ActivationConditionProp,
Affected, Change, ChangeResult, GetProcessingRelevance, MakeFxNonStickyMode,
MakeTrackNonStickyMode, MappingExtensionModel, ModeCommand, ModeModel, ModeProp,
ProcessingRelevance, SourceCommand, SourceModel, SourceProp, TargetCategory, TargetCommand,
TargetModel, TargetModelFormatVeryShort, TargetModelWithContext, TargetProp,
};
use crate::domain::{
ActivationCondition, CompartmentKind, CompoundMappingSource, CompoundMappingTarget,
EelTransformation, ExtendedProcessorContext, ExtendedSourceCharacter, FeedbackSendBehavior,
GroupId, MainMapping, MappingId, MappingKey, Mode, PersistentMappingProcessingState,
ProcessorMappingOptions, QualifiedMappingId, RealearnTarget, ReaperTarget, Script, Tag,
TargetCharacter, UnresolvedCompoundMappingTarget, VirtualFx, VirtualTrack,
};
use helgoboss_learn::{
AbsoluteMode, ControlType, DetailedSourceCharacter, DiscreteIncrement, Interval,
ModeApplicabilityCheckInput, ModeParameter, SourceCharacter, Target, UnitValue,
};
use reaper_high::{Fx, Track};
use std::cell::RefCell;
use std::error::Error;
use std::rc::Rc;
pub enum MappingCommand {
SetName(String),
SetTags(Vec<Tag>),
SetGroupId(GroupId),
SetIsEnabled(bool),
SetControlIsEnabled(bool),
SetFeedbackIsEnabled(bool),
SetFeedbackSendBehavior(FeedbackSendBehavior),
SetVisibleInProjection(bool),
SetBeepOnSuccess(bool),
ChangeActivationCondition(ActivationConditionCommand),
ChangeSource(SourceCommand),
ChangeMode(ModeCommand),
ChangeTarget(TargetCommand),
}
#[derive(Eq, PartialEq)]
pub enum MappingProp {
Name,
Tags,
GroupId,
IsEnabled,
ControlIsEnabled,
FeedbackIsEnabled,
FeedbackSendBehavior,
VisibleInProjection,
BeepOnSuccess,
AdvancedSettings,
InActivationCondition(Affected<ActivationConditionProp>),
InSource(Affected<SourceProp>),
InMode(Affected<ModeProp>),
InTarget(Affected<TargetProp>),
}
impl GetProcessingRelevance for MappingProp {
fn processing_relevance(&self) -> Option<ProcessingRelevance> {
use MappingProp as P;
match self {
P::Name
| P::Tags
| P::ControlIsEnabled
| P::FeedbackIsEnabled
| P::FeedbackSendBehavior
| P::VisibleInProjection
| P::AdvancedSettings
| P::BeepOnSuccess => Some(ProcessingRelevance::ProcessingRelevant),
P::InActivationCondition(p) => p.processing_relevance(),
P::InMode(p) => p.processing_relevance(),
P::InSource(p) => p.processing_relevance(),
P::InTarget(p) => p.processing_relevance(),
P::IsEnabled => Some(ProcessingRelevance::PersistentProcessingRelevant),
MappingProp::GroupId => {
// This is handled in different ways.
None
}
}
}
}
/// A model for creating mappings (a combination of source, mode and target).
#[derive(Clone, Debug)]
pub struct MappingModel {
id: MappingId,
key: MappingKey,
compartment: CompartmentKind,
name: String,
tags: Vec<Tag>,
group_id: GroupId,
is_enabled: bool,
control_is_enabled: bool,
feedback_is_enabled: bool,
feedback_send_behavior: FeedbackSendBehavior,
pub activation_condition_model: ActivationConditionModel,
visible_in_projection: bool,
beep_on_success: bool,
pub source_model: SourceModel,
pub mode_model: ModeModel,
pub target_model: TargetModel,
advanced_settings: Option<serde_yaml::mapping::Mapping>,
extension_model: MappingExtensionModel,
}
pub type SharedMapping = Rc<RefCell<MappingModel>>;
pub fn share_mapping(mapping: MappingModel) -> SharedMapping {
Rc::new(RefCell::new(mapping))
}
// We design mapping models as entity (in the DDD sense), so we compare them by ID, not by value.
// Because we store everything in memory instead of working with a database, the memory
// address serves us as ID. That means we just compare pointers.
//
// In all functions which don't need access to the mapping's internal state (comparisons, hashing
// etc.) we use `*const MappingModel` as parameter type because this saves the consumer from
// having to borrow the mapping (when kept in a RefCell). Whenever we can we should compare pointers
// directly, in order to prevent borrowing just to make the following comparison (the RefCell
// comparison internally calls `borrow()`!).
impl PartialEq for MappingModel {
fn eq(&self, other: &Self) -> bool {
std::ptr::eq(self as _, other as _)
}
}
impl Change<'_> for MappingModel {
type Command = MappingCommand;
type Prop = MappingProp;
fn change(&mut self, cmd: MappingCommand) -> Option<Affected<MappingProp>> {
use Affected::*;
use MappingCommand as C;
use MappingProp as P;
let affected = match cmd {
C::SetName(v) => {
self.name = v;
One(P::Name)
}
C::SetTags(v) => {
self.tags = v;
One(P::Tags)
}
C::SetGroupId(v) => {
self.group_id = v;
One(P::GroupId)
}
C::SetIsEnabled(v) => {
self.is_enabled = v;
One(P::IsEnabled)
}
C::SetControlIsEnabled(v) => {
self.control_is_enabled = v;
One(P::ControlIsEnabled)
}
C::SetFeedbackIsEnabled(v) => {
self.feedback_is_enabled = v;
One(P::FeedbackIsEnabled)
}
C::SetFeedbackSendBehavior(v) => {
self.feedback_send_behavior = v;
One(P::FeedbackSendBehavior)
}
C::SetVisibleInProjection(v) => {
self.visible_in_projection = v;
One(P::VisibleInProjection)
}
C::SetBeepOnSuccess(v) => {
self.beep_on_success = v;
One(P::BeepOnSuccess)
}
C::ChangeActivationCondition(cmd) => {
return self
.activation_condition_model
.change(cmd)
.map(|affected| One(P::InActivationCondition(affected)));
}
C::ChangeSource(cmd) => {
return self
.source_model
.change(cmd)
.map(|affected| One(P::InSource(affected)));
}
C::ChangeMode(cmd) => {
return self
.mode_model
.change(cmd)
.map(|affected| One(P::InMode(affected)));
}
C::ChangeTarget(cmd) => {
return self
.target_model
.change(cmd)
.map(|affected| One(P::InTarget(affected)));
}
};
Some(affected)
}
}
impl MappingModel {
pub fn new(
compartment: CompartmentKind,
initial_group_id: GroupId,
key: MappingKey,
id: MappingId,
) -> Self {
Self {
id,
key,
compartment,
name: Default::default(),
tags: Default::default(),
group_id: initial_group_id,
is_enabled: true,
control_is_enabled: true,
feedback_is_enabled: true,
feedback_send_behavior: Default::default(),
activation_condition_model: Default::default(),
visible_in_projection: true,
beep_on_success: false,
source_model: SourceModel::new(),
mode_model: Default::default(),
target_model: TargetModel::default_for_compartment(compartment),
advanced_settings: None,
extension_model: Default::default(),
}
}
pub fn id(&self) -> MappingId {
self.id
}
pub fn key(&self) -> &MappingKey {
&self.key
}
pub fn group_id(&self) -> GroupId {
self.group_id
}
pub fn is_enabled(&self) -> bool {
self.is_enabled
}
pub fn control_is_enabled(&self) -> bool {
self.control_is_enabled
}
pub fn feedback_is_enabled(&self) -> bool {
self.feedback_is_enabled
}
pub fn feedback_send_behavior(&self) -> FeedbackSendBehavior {
self.feedback_send_behavior
}
pub fn visible_in_projection(&self) -> bool {
self.visible_in_projection
}
pub fn beep_on_success(&self) -> bool {
self.beep_on_success
}
pub fn activation_condition_model(&self) -> &ActivationConditionModel {
&self.activation_condition_model
}
pub fn reset_key(&mut self) {
self.key = MappingKey::random();
}
pub fn qualified_id(&self) -> QualifiedMappingId {
QualifiedMappingId::new(self.compartment, self.id)
}
pub fn name(&self) -> &str {
&self.name
}
pub fn tags(&self) -> &[Tag] {
&self.tags
}
pub fn effective_name(&self) -> String {
if self.name.is_empty() {
TargetModelFormatVeryShort(&self.target_model).to_string()
} else {
self.name.clone()
}
}
pub fn make_target_non_sticky(
&mut self,
context: ExtendedProcessorContext,
track_mode: MakeTrackNonStickyMode,
fx_mode: MakeFxNonStickyMode,
) -> Option<Affected<MappingProp>> {
self.make_target_non_sticky_internal(
context,
|t| track_mode.build_virtual_track(t.as_ref()),
|fx| fx_mode.build_virtual_fx(fx.as_ref()),
)
}
#[must_use]
fn make_target_non_sticky_internal(
&mut self,
context: ExtendedProcessorContext,
create_virtual_track: impl FnOnce(Option<Track>) -> Option<VirtualTrack>,
create_virtual_fx: impl FnOnce(Option<Fx>) -> Option<VirtualFx>,
) -> Option<Affected<MappingProp>> {
let compartment = self.compartment();
let target = &mut self.target_model;
match target.category() {
TargetCategory::Reaper => {
// Change FX
if target.supports_fx() {
let target_with_context = target.with_context(context, compartment);
let containing_fx = context.context().containing_fx();
let resolved_fx = target_with_context.first_fx().ok();
let new_virtual_fx = if resolved_fx.as_ref() == Some(containing_fx) {
// This is ourselves!
Some(VirtualFx::This)
} else {
create_virtual_fx(resolved_fx)
};
if let Some(fx) = new_virtual_fx {
let _ = target.set_virtual_fx(fx, context, compartment);
}
}
// Change track
if target.target_type().supports_track() {
let new_virtual_track = if target.fx_type().requires_fx_chain() {
let resolved_track = target
.with_context(context, compartment)
.first_effective_track()
.ok();
create_virtual_track(resolved_track)
} else {
// Track doesn't matter at all. We change it to <This>. Looks nice.
Some(VirtualTrack::This)
};
if let Some(t) = new_virtual_track {
let _ = target.set_virtual_track(t, Some(context.context()));
}
}
Some(Affected::Multiple)
}
TargetCategory::Virtual => None,
}
}
pub fn make_target_sticky(
&mut self,
context: ExtendedProcessorContext,
) -> Result<Option<Affected<MappingProp>>, Box<dyn Error>> {
let target = &mut self.target_model;
match target.category() {
TargetCategory::Reaper => {
if target.supports_track() {
target.make_track_sticky(self.compartment, context)?;
}
if target.supports_fx() {
target.make_fx_sticky(self.compartment, context)?;
}
if target.supports_route() {
target.make_route_sticky(self.compartment, context)?;
}
}
TargetCategory::Virtual => {}
}
Ok(Some(Affected::Multiple))
}
pub fn advanced_settings(&self) -> Option<&serde_yaml::Mapping> {
self.advanced_settings.as_ref()
}
fn update_extension_model_from_advanced_settings(&mut self) -> Result<(), String> {
// Immediately update extension model
let extension_model = if let Some(yaml_mapping) = self.advanced_settings() {
serde_yaml::from_value(serde_yaml::Value::Mapping(yaml_mapping.clone()))
.map_err(|e| e.to_string())?
} else {
Default::default()
};
self.extension_model = extension_model;
Ok(())
}
pub fn duplicate(&self) -> MappingModel {
MappingModel {
id: MappingId::random(),
key: MappingKey::random(),
..self.clone()
}
}
pub fn compartment(&self) -> CompartmentKind {
self.compartment
}
pub fn with_context<'a>(
&'a self,
context: ExtendedProcessorContext<'a>,
) -> MappingModelWithContext<'a> {
MappingModelWithContext {
mapping: self,
context,
}
}
#[must_use]
pub fn adjust_mode_if_necessary(
&mut self,
context: ExtendedProcessorContext,
) -> Option<Affected<MappingProp>> {
let with_context = self.with_context(context);
if with_context.absolute_mode_makes_sense() == Ok(false) {
if let Ok(preferred_mode_type) = with_context.preferred_mode_type() {
self.mode_model
.change(ModeCommand::SetAbsoluteMode(preferred_mode_type));
self.set_preferred_mode_values(context)
} else {
None
}
} else {
None
}
}
#[must_use]
pub fn reset_mode(
&mut self,
context: ExtendedProcessorContext,
) -> Option<Affected<MappingProp>> {
self.mode_model.change(ModeCommand::ResetWithinType);
let _ = self.set_preferred_mode_values(context);
Some(Affected::Multiple)
}
pub fn set_advanced_settings(
&mut self,
yaml: Option<serde_yaml::mapping::Mapping>,
) -> ChangeResult<MappingProp> {
self.advanced_settings = yaml;
self.update_extension_model_from_advanced_settings()?;
Ok(Some(Affected::One(MappingProp::AdvancedSettings)))
}
#[must_use]
pub fn set_absolute_mode_and_preferred_values(
&mut self,
context: ExtendedProcessorContext,
mode: AbsoluteMode,
) -> Option<Affected<MappingProp>> {
let affected_1 = self.change(MappingCommand::ChangeMode(ModeCommand::SetAbsoluteMode(
mode,
)));
let affected_2 = self.set_preferred_mode_values(context);
merge_affected(affected_1, affected_2)
}
// Changes mode settings if there are some preferred ones for a certain source or target.
#[must_use]
fn set_preferred_mode_values(
&mut self,
context: ExtendedProcessorContext,
) -> Option<Affected<MappingProp>> {
let affected_1 = self
.mode_model
.change(ModeCommand::SetStepSizeInterval(
self.with_context(context).preferred_step_size_interval(),
))
.map(|affected| Affected::One(MappingProp::InMode(affected)));
let affected_2 = self
.mode_model
.change(ModeCommand::SetStepFactorInterval(
self.with_context(context).preferred_step_factor_interval(),
))
.map(|affected| Affected::One(MappingProp::InMode(affected)));
merge_affected(affected_1, affected_2)
}
pub fn base_mode_applicability_check_input(&self) -> ModeApplicabilityCheckInput {
let transformation =
EelTransformation::compile_for_control(self.mode_model.eel_control_transformation());
ModeApplicabilityCheckInput {
target_is_virtual: self.target_model.is_virtual(),
// TODO-high-discrete Enable (also taking source into consideration!)
target_supports_discrete_values: false,
control_transformation_uses_time: transformation
.as_ref()
.map(|t| t.uses_time())
.unwrap_or(false),
control_transformation_produces_relative_values: transformation
.as_ref()
.map(|t| t.produces_relative_values())
.unwrap_or(false),
is_feedback: false,
make_absolute: self.mode_model.make_absolute(),
use_textual_feedback: self.mode_model.feedback_type().is_textual(),
// Any is okay, will be overwritten.
source_character: DetailedSourceCharacter::RangeControl,
absolute_mode: self.mode_model.absolute_mode(),
fire_mode: self.mode_model.fire_mode(),
target_value_sequence_is_set: !self.mode_model.target_value_sequence().is_empty(),
}
}
pub fn control_is_enabled_and_supported(&self) -> bool {
self.control_is_enabled()
&& self.source_model.supports_control()
&& self.target_model.supports_control()
}
pub fn feedback_is_enabled_and_supported(&self) -> bool {
self.feedback_is_enabled()
&& self.source_model.supports_feedback()
&& self.target_model.supports_feedback()
}
pub fn mode_parameter_is_relevant(
&self,
mode_parameter: ModeParameter,
base_input: ModeApplicabilityCheckInput,
possible_source_characters: &[DetailedSourceCharacter],
) -> bool {
self.mode_model.mode_parameter_is_relevant(
mode_parameter,
base_input,
possible_source_characters,
self.control_is_enabled_and_supported(),
self.feedback_is_enabled_and_supported(),
)
}
fn create_source(&self) -> CompoundMappingSource {
self.source_model.create_source()
}
fn create_mode(&self) -> Mode {
let possible_source_characters = self.source_model.possible_detailed_characters();
self.mode_model.create_mode(
self.base_mode_applicability_check_input(),
&possible_source_characters,
)
}
fn create_target(&self) -> Option<UnresolvedCompoundMappingTarget> {
self.target_model.create_target(self.compartment).ok()
}
pub fn create_persistent_mapping_processing_state(&self) -> PersistentMappingProcessingState {
PersistentMappingProcessingState {
is_enabled: self.is_enabled(),
}
}
pub fn get_simple_mapping(&self) -> Option<playtime_api::runtime::SimpleMapping> {
let target = self.target_model.simple_target()?;
let source = self.source_model.simple_source()?;
let mapping = playtime_api::runtime::SimpleMapping { source, target };
Some(mapping)
}
/// Creates an intermediate mapping for splintering into very dedicated mapping types that are
/// then going to be distributed to real-time and main processor.
pub fn create_main_mapping(&self, group_data: GroupData) -> MainMapping {
let id = self.id;
let source = self.create_source();
let mode = self.create_mode();
let unresolved_target = self.create_target();
let activation_condition = self
.activation_condition_model
.create_activation_condition();
let options = ProcessorMappingOptions {
// TODO-medium Encapsulate, don't set here
target_is_active: false,
persistent_processing_state: self.create_persistent_mapping_processing_state(),
control_is_enabled: group_data.control_is_enabled && self.control_is_enabled(),
feedback_is_enabled: group_data.feedback_is_enabled && self.feedback_is_enabled(),
feedback_send_behavior: self.feedback_send_behavior(),
beep_on_success: self.beep_on_success,
};
let mut merged_tags = group_data.tags;
merged_tags.extend_from_slice(&self.tags);
MainMapping::new(
self.compartment,
id,
&self.key,
self.group_id(),
self.name.clone(),
merged_tags,
source,
mode,
self.mode_model.group_interaction(),
unresolved_target,
group_data.activation_condition,
activation_condition,
options,
self.extension_model
.create_mapping_extension()
.unwrap_or_default(),
)
}
}
pub struct GroupData {
pub control_is_enabled: bool,
pub feedback_is_enabled: bool,
pub activation_condition: ActivationCondition,
pub tags: Vec<Tag>,
}
impl Default for GroupData {
fn default() -> Self {
Self {
control_is_enabled: true,
feedback_is_enabled: true,
activation_condition: ActivationCondition::Always,
tags: vec![],
}
}
}
pub struct MappingModelWithContext<'a> {
mapping: &'a MappingModel,
context: ExtendedProcessorContext<'a>,
}
impl MappingModelWithContext<'_> {
/// Returns if the absolute make sense under the current conditions.
///
/// Conditions are:
///
/// - Source character
/// - Target character and control type
pub fn absolute_mode_makes_sense(&self) -> Result<bool, &'static str> {
use ExtendedSourceCharacter::*;
use SourceCharacter::*;
let source_character = self.mapping.source_model.character();
let absolute_mode = self.mapping.mode_model.absolute_mode();
let makes_sense = match source_character {
Normal(RangeElement) => match absolute_mode {
AbsoluteMode::Normal
| AbsoluteMode::MakeRelative
| AbsoluteMode::PerformanceControl => true,
AbsoluteMode::IncrementalButton | AbsoluteMode::ToggleButton => false,
},
Normal(MomentaryButton | ToggleButton) => {
let target = self.target_with_context().resolve_first()?;
let target_is_relative = target
.control_type(self.context.control_context())
.is_relative();
match absolute_mode {
AbsoluteMode::Normal | AbsoluteMode::ToggleButton => !target_is_relative,
AbsoluteMode::IncrementalButton => {
if target_is_relative {
true
} else {
match target.character(self.context.control_context()) {
TargetCharacter::Discrete
| TargetCharacter::Continuous
| TargetCharacter::VirtualMulti => true,
TargetCharacter::Trigger
| TargetCharacter::Switch
| TargetCharacter::VirtualButton => false,
}
}
}
AbsoluteMode::MakeRelative => {
// "Incremental button" is the correct special form of "Make relative"
// for button presses!
false
}
AbsoluteMode::PerformanceControl => false,
}
}
Normal(Encoder1) | Normal(Encoder2) | Normal(Encoder3) => {
// TODO-low No idea why this is true. But so what, auto-correct settings is not
// really a thing anymore?
true
}
VirtualContinuous => true,
};
Ok(makes_sense)
}
pub fn has_target(&self, target: &ReaperTarget) -> bool {
self.target_with_context()
.resolve()
.iter()
.flatten()
.any(|t| match t {
CompoundMappingTarget::Reaper(t) => &**t == target,
_ => false,
})
}
pub fn preferred_mode_type(&self) -> Result<AbsoluteMode, &'static str> {
use ExtendedSourceCharacter::*;
use SourceCharacter::*;
let result = match self.mapping.source_model.character() {
Normal(RangeElement) | VirtualContinuous => AbsoluteMode::Normal,
Normal(MomentaryButton) | Normal(ToggleButton) => {
let target = self.target_with_context().resolve_first()?;
if target
.control_type(self.context.control_context())
.is_relative()
{
AbsoluteMode::IncrementalButton
} else {
match target.character(self.context.control_context()) {
TargetCharacter::Trigger
| TargetCharacter::Continuous
| TargetCharacter::VirtualMulti => AbsoluteMode::Normal,
TargetCharacter::Switch | TargetCharacter::VirtualButton => {
AbsoluteMode::ToggleButton
}
TargetCharacter::Discrete => AbsoluteMode::IncrementalButton,
}
}
}
Normal(Encoder1) | Normal(Encoder2) | Normal(Encoder3) => AbsoluteMode::Normal,
};
Ok(result)
}
/// If this returns `true`, the Speed sliders will be shown, allowing relative
/// increments/decrements to be throttled or multiplied.
pub fn uses_step_factors(&self) -> bool {
let mode = self.mapping.create_mode();
if mode.settings().make_absolute {
// If we convert increments to absolute values, we want step sizes of course.
return false;
}
if !mode.settings().target_value_sequence.is_empty() {
// If we have a target value sequence, we are discrete all the way!
return true;
}
let target = match self.target_with_context().resolve_first().ok() {
None => return false,
Some(t) => t,
};
match target.control_type(self.context.control_context()) {
ControlType::AbsoluteContinuousRetriggerable => {
// Retriggerable targets which can't report the current value and are pure triggers.
// In #613, we introduced a convenient behavior that allows encoder movements
// trigger such targets. But we want to support throttling the encoder speed, so
// we consider this as using step counts.
!target.can_report_current_value()
}
ControlType::AbsoluteContinuous => false,
ControlType::AbsoluteContinuousRoundable { .. } => false,
ControlType::AbsoluteDiscrete { .. } => true,
ControlType::Relative => true,
ControlType::VirtualMulti => true,
ControlType::VirtualButton => false,
}
}
fn preferred_step_size_interval(&self) -> Interval<UnitValue> {
match self.target_step_size() {
Some(step_size) => Interval::new(step_size, step_size),
None => ModeModel::default_step_size_interval(),
}
}
fn preferred_step_factor_interval(&self) -> Interval<DiscreteIncrement> {
let inc = DiscreteIncrement::new(1);
Interval::new(inc, inc)
}
fn target_step_size(&self) -> Option<UnitValue> {
let target = self.target_with_context().resolve_first().ok()?;
target
.control_type(self.context.control_context())
.step_size()
}
fn target_with_context(&self) -> TargetModelWithContext<'_> {
self.mapping
.target_model
.with_context(self.context, self.mapping.compartment)
}
}
@@ -0,0 +1,49 @@
mod instance_model;
pub use instance_model::*;
mod unit_model;
pub use unit_model::*;
mod source_model;
pub use source_model::*;
mod mode_model;
pub use mode_model::*;
mod target_model;
pub use target_model::*;
mod mapping_model;
pub use mapping_model::*;
mod group_model;
pub use group_model::*;
mod activation_condition_model;
pub use activation_condition_model::*;
mod compartment_preset;
pub use compartment_preset::*;
mod conditional_activation_model;
pub use conditional_activation_model::*;
mod preset_link;
pub use preset_link::*;
mod mapping_extension_model;
pub use mapping_extension_model::*;
mod compartment_model;
pub use compartment_model::*;
mod props;
pub use props::*;
mod auto_units;
mod actions;
pub use actions::*;
pub use auto_units::*;
@@ -0,0 +1,670 @@
use crate::domain::{Backbone, EelTransformation, LuaFeedbackScript, Mode};
use helgoboss_learn::{
check_mode_applicability, create_unit_value_interval, full_discrete_interval,
full_unit_interval, AbsoluteMode, ButtonUsage, DetailedSourceCharacter, DiscreteIncrement,
EncoderUsage, FeedbackProcessor, FeedbackType, FireMode, GroupInteraction, Interval,
ModeApplicabilityCheckInput, ModeParameter, ModeSettings, OutOfRangeBehavior, TakeoverMode,
UnitValue, ValueSequence, VirtualColor,
};
use crate::application::{Affected, Change, GetProcessingRelevance, ProcessingRelevance};
use crate::base::CloneAsDefault;
use base::hash_util::clone_to_other_hash_map;
use helgobox_api::persistence::FeedbackValueTable;
use std::time::Duration;
pub enum ModeCommand {
SetAbsoluteMode(AbsoluteMode),
SetTargetValueInterval(Interval<UnitValue>),
SetMinTargetValue(UnitValue),
SetMaxTargetValue(UnitValue),
SetSourceValueInterval(Interval<UnitValue>),
SetMinSourceValue(UnitValue),
SetMaxSourceValue(UnitValue),
SetReverse(bool),
SetPressDurationInterval(Interval<Duration>),
SetMinPressDuration(Duration),
SetMaxPressDuration(Duration),
SetTurboRate(Duration),
SetLegacyJumpInterval(Option<Interval<UnitValue>>),
SetOutOfRangeBehavior(OutOfRangeBehavior),
SetFireMode(FireMode),
SetRoundTargetValue(bool),
SetTakeoverMode(TakeoverMode),
SetButtonUsage(ButtonUsage),
SetEncoderUsage(EncoderUsage),
SetEelControlTransformation(String),
SetEelFeedbackTransformation(String),
SetStepSizeInterval(Interval<UnitValue>),
SetStepFactorInterval(Interval<DiscreteIncrement>),
SetMinStepSize(UnitValue),
SetMaxStepSize(UnitValue),
SetMinStepFactor(DiscreteIncrement),
SetMaxStepFactor(DiscreteIncrement),
SetRotate(bool),
SetMakeAbsolute(bool),
SetGroupInteraction(GroupInteraction),
SetTargetValueSequence(ValueSequence),
SetFeedbackType(FeedbackType),
SetTextualFeedbackExpression(String),
SetFeedbackColor(Option<VirtualColor>),
SetFeedbackBackgroundColor(Option<VirtualColor>),
SetFeedbackValueTable(Option<FeedbackValueTable>),
/// This doesn't reset the mode type, just all the values.
ResetWithinType,
}
#[derive(Eq, PartialEq)]
pub enum ModeProp {
AbsoluteMode,
TargetValueInterval,
SourceValueInterval,
Reverse,
PressDurationInterval,
TurboRate,
LegacyJumpInterval,
OutOfRangeBehavior,
FireMode,
RoundTargetValue,
TakeoverMode,
ButtonUsage,
EncoderUsage,
EelControlTransformation,
EelFeedbackTransformation,
StepSizeInterval,
StepFactorInterval,
Rotate,
MakeAbsolute,
GroupInteraction,
TargetValueSequence,
FeedbackType,
TextualFeedbackExpression,
FeedbackColor,
FeedbackBackgroundColor,
FeedbackValueTable,
}
impl GetProcessingRelevance for ModeProp {
fn processing_relevance(&self) -> Option<ProcessingRelevance> {
// At the moment, all mode aspects are relevant for processing.
Some(ProcessingRelevance::ProcessingRelevant)
}
}
/// A model for creating modes
#[derive(Clone, Debug)]
pub struct ModeModel {
absolute_mode: AbsoluteMode,
target_value_interval: Interval<UnitValue>,
source_value_interval: Interval<UnitValue>,
reverse: bool,
press_duration_interval: Interval<Duration>,
turbo_rate: Duration,
/// Since 2.14.0-pre.10, this should be `None` for all new mappings.
///
/// In this case, a dynamic jump interval will be used.
///
/// This is only set for old presets in order to not change behavior.
legacy_jump_interval: Option<Interval<UnitValue>>,
out_of_range_behavior: OutOfRangeBehavior,
fire_mode: FireMode,
round_target_value: bool,
takeover_mode: TakeoverMode,
button_usage: ButtonUsage,
encoder_usage: EncoderUsage,
eel_control_transformation: String,
eel_feedback_transformation: String,
// For relative control values.
/// A step size is the positive, absolute size of an increment. 0.0 represents no increment,
/// 1.0 represents an increment over the whole value range (not very useful).
///
/// It's an interval. When using rotary encoders, the most important value is the interval
/// minimum. There are some controllers which deliver higher increments if turned faster. This
/// is where the maximum comes in. The maximum is also important if using the relative mode
/// with buttons. The harder you press the button, the higher the increment. It's limited
/// by the maximum value.
step_size_interval: Interval<UnitValue>,
/// A step factor is a coefficient which multiplies the atomic step size. E.g. a step count of 2
/// can be read as 2 * step_size which means double speed. When the step count is negative,
/// it's interpreted as a fraction of 1. E.g. a step count of -2 is 1/2 * step_size which
/// means half speed. The increment is fired only every nth time, which results in a
/// slow-down, or in other words, less sensitivity.
step_factor_interval: Interval<DiscreteIncrement>,
rotate: bool,
make_absolute: bool,
group_interaction: GroupInteraction,
target_value_sequence: ValueSequence,
feedback_type: FeedbackType,
textual_feedback_expression: String,
feedback_color: Option<VirtualColor>,
feedback_background_color: Option<VirtualColor>,
feedback_value_table: Option<FeedbackValueTable>,
}
impl Default for ModeModel {
fn default() -> Self {
Self {
absolute_mode: AbsoluteMode::Normal,
target_value_interval: full_unit_interval(),
source_value_interval: full_unit_interval(),
reverse: false,
press_duration_interval: Interval::new(
Duration::from_millis(0),
Duration::from_millis(0),
),
turbo_rate: Duration::from_millis(0),
legacy_jump_interval: None,
out_of_range_behavior: Default::default(),
fire_mode: Default::default(),
round_target_value: false,
takeover_mode: Default::default(),
button_usage: Default::default(),
encoder_usage: Default::default(),
eel_control_transformation: String::new(),
eel_feedback_transformation: String::new(),
step_size_interval: Self::default_step_size_interval(),
step_factor_interval: Self::default_step_factor_interval(),
rotate: false,
make_absolute: false,
group_interaction: Default::default(),
target_value_sequence: Default::default(),
feedback_type: Default::default(),
textual_feedback_expression: Default::default(),
feedback_color: Default::default(),
feedback_background_color: Default::default(),
feedback_value_table: None,
}
}
}
impl Change<'_> for ModeModel {
type Command = ModeCommand;
type Prop = ModeProp;
fn change(&mut self, cmd: ModeCommand) -> Option<Affected<ModeProp>> {
use Affected::*;
use ModeCommand as C;
use ModeProp as P;
let affected = match cmd {
C::SetAbsoluteMode(v) => {
self.absolute_mode = v;
One(P::AbsoluteMode)
}
C::SetTargetValueInterval(v) => {
self.target_value_interval = v;
One(P::TargetValueInterval)
}
C::SetMinTargetValue(v) => {
return self.change(C::SetTargetValueInterval(
self.target_value_interval.with_min(v),
))
}
C::SetMaxTargetValue(v) => {
return self.change(C::SetTargetValueInterval(
self.target_value_interval.with_max(v),
))
}
C::SetSourceValueInterval(v) => {
self.source_value_interval = v;
One(P::SourceValueInterval)
}
C::SetMinSourceValue(v) => {
return self.change(C::SetSourceValueInterval(
self.source_value_interval.with_min(v),
))
}
C::SetMaxSourceValue(v) => {
return self.change(C::SetSourceValueInterval(
self.source_value_interval.with_max(v),
))
}
C::SetReverse(v) => {
self.reverse = v;
One(P::Reverse)
}
C::SetPressDurationInterval(v) => {
self.press_duration_interval = v;
One(P::PressDurationInterval)
}
C::SetMinPressDuration(v) => {
return self.change(C::SetPressDurationInterval(
self.press_duration_interval.with_min(v),
))
}
C::SetMaxPressDuration(v) => {
return self.change(C::SetPressDurationInterval(
self.press_duration_interval.with_max(v),
))
}
C::SetTurboRate(v) => {
self.turbo_rate = v;
One(P::TurboRate)
}
C::SetLegacyJumpInterval(v) => {
self.legacy_jump_interval = v;
One(P::LegacyJumpInterval)
}
C::SetOutOfRangeBehavior(v) => {
self.out_of_range_behavior = v;
One(P::OutOfRangeBehavior)
}
C::SetFireMode(v) => {
self.fire_mode = v;
One(P::FireMode)
}
C::SetRoundTargetValue(v) => {
self.round_target_value = v;
One(P::RoundTargetValue)
}
C::SetTakeoverMode(v) => {
self.takeover_mode = v;
One(P::TakeoverMode)
}
C::SetButtonUsage(v) => {
self.button_usage = v;
One(P::ButtonUsage)
}
C::SetEncoderUsage(v) => {
self.encoder_usage = v;
One(P::EncoderUsage)
}
C::SetEelControlTransformation(v) => {
self.eel_control_transformation = v;
One(P::EelControlTransformation)
}
C::SetEelFeedbackTransformation(v) => {
self.eel_feedback_transformation = v;
One(P::EelFeedbackTransformation)
}
C::SetStepSizeInterval(v) => {
self.step_size_interval = v;
One(P::StepSizeInterval)
}
C::SetStepFactorInterval(v) => {
self.step_factor_interval = v;
One(P::StepFactorInterval)
}
C::SetMinStepSize(v) => {
return self.change(C::SetStepSizeInterval(self.step_size_interval.with_min(v)))
}
C::SetMaxStepSize(v) => {
return self.change(C::SetStepSizeInterval(self.step_size_interval.with_max(v)))
}
C::SetMinStepFactor(v) => {
return self.change(C::SetStepFactorInterval(
self.step_factor_interval.with_min(v),
))
}
C::SetMaxStepFactor(v) => {
return self.change(C::SetStepFactorInterval(
self.step_factor_interval.with_max(v),
))
}
C::SetRotate(v) => {
self.rotate = v;
One(P::Rotate)
}
C::SetMakeAbsolute(v) => {
self.make_absolute = v;
One(P::MakeAbsolute)
}
C::SetGroupInteraction(v) => {
self.group_interaction = v;
One(P::GroupInteraction)
}
C::SetTargetValueSequence(v) => {
self.target_value_sequence = v;
One(P::TargetValueSequence)
}
C::SetFeedbackType(v) => {
self.feedback_type = v;
One(P::FeedbackType)
}
C::SetTextualFeedbackExpression(v) => {
self.textual_feedback_expression = v;
One(P::TextualFeedbackExpression)
}
C::SetFeedbackColor(v) => {
self.feedback_color = v;
One(P::FeedbackColor)
}
C::SetFeedbackBackgroundColor(v) => {
self.feedback_background_color = v;
One(P::FeedbackBackgroundColor)
}
C::SetFeedbackValueTable(v) => {
self.feedback_value_table = v;
One(P::FeedbackValueTable)
}
C::ResetWithinType => {
*self = Default::default();
Multiple
}
};
Some(affected)
}
}
impl ModeModel {
pub fn default_step_size_interval() -> Interval<UnitValue> {
// 0.01 has been chosen as default minimum step size because it corresponds to 1%.
//
// 0.05 has been chosen as default maximum step size in order to make users aware that
// ReaLearn supports encoder acceleration ("dial harder = more increments") and
// velocity-sensitive buttons ("press harder = more increments") but still is low
// enough to not lead to surprising results such as ugly parameter jumps.
Interval::new(UnitValue::new(0.01), UnitValue::new(0.05))
}
pub fn default_step_factor_interval() -> Interval<DiscreteIncrement> {
Interval::new(DiscreteIncrement::new(1), DiscreteIncrement::new(5))
}
pub fn feedback_value_table(&self) -> Option<&FeedbackValueTable> {
self.feedback_value_table.as_ref()
}
pub fn absolute_mode(&self) -> AbsoluteMode {
self.absolute_mode
}
pub fn target_value_interval(&self) -> Interval<UnitValue> {
self.target_value_interval
}
pub fn source_value_interval(&self) -> Interval<UnitValue> {
self.source_value_interval
}
pub fn reverse(&self) -> bool {
self.reverse
}
pub fn press_duration_interval(&self) -> Interval<Duration> {
self.press_duration_interval
}
pub fn turbo_rate(&self) -> Duration {
self.turbo_rate
}
pub fn legacy_jump_interval(&self) -> Option<Interval<UnitValue>> {
self.legacy_jump_interval
}
pub fn out_of_range_behavior(&self) -> OutOfRangeBehavior {
self.out_of_range_behavior
}
pub fn fire_mode(&self) -> FireMode {
self.fire_mode
}
pub fn round_target_value(&self) -> bool {
self.round_target_value
}
pub fn takeover_mode(&self) -> TakeoverMode {
self.takeover_mode
}
pub fn button_usage(&self) -> ButtonUsage {
self.button_usage
}
pub fn encoder_usage(&self) -> EncoderUsage {
self.encoder_usage
}
pub fn eel_control_transformation(&self) -> &str {
&self.eel_control_transformation
}
pub fn eel_feedback_transformation(&self) -> &str {
&self.eel_feedback_transformation
}
pub fn step_size_interval(&self) -> Interval<UnitValue> {
self.step_size_interval
}
pub fn step_factor_interval(&self) -> Interval<DiscreteIncrement> {
self.step_factor_interval
}
pub fn rotate(&self) -> bool {
self.rotate
}
pub fn make_absolute(&self) -> bool {
self.make_absolute
}
pub fn group_interaction(&self) -> GroupInteraction {
self.group_interaction
}
pub fn target_value_sequence(&self) -> &ValueSequence {
&self.target_value_sequence
}
pub fn feedback_type(&self) -> FeedbackType {
self.feedback_type
}
pub fn textual_feedback_expression(&self) -> &str {
&self.textual_feedback_expression
}
pub fn feedback_color(&self) -> Option<&VirtualColor> {
self.feedback_color.as_ref()
}
pub fn feedback_background_color(&self) -> Option<&VirtualColor> {
self.feedback_background_color.as_ref()
}
pub fn mode_parameter_is_relevant(
&self,
mode_parameter: ModeParameter,
base_input: ModeApplicabilityCheckInput,
possible_source_characters: &[DetailedSourceCharacter],
control_is_relevant: bool,
feedback_is_relevant: bool,
) -> bool {
possible_source_characters.iter().any(|source_character| {
let is_applicable = |is_feedback| {
let input = ModeApplicabilityCheckInput {
is_feedback,
source_character: *source_character,
..base_input
};
check_mode_applicability(mode_parameter, input).is_relevant()
};
(control_is_relevant && is_applicable(false))
|| (feedback_is_relevant && is_applicable(true))
})
}
/// Creates a mode reflecting this model's current values
#[allow(clippy::if_same_then_else)]
pub fn create_mode(
&self,
base_input: ModeApplicabilityCheckInput,
possible_source_characters: &[DetailedSourceCharacter],
) -> Mode {
let is_relevant = |mode_parameter: ModeParameter| {
// We take both control and feedback into account to not accidentally get slightly
// different behavior if feedback is not enabled.
self.mode_parameter_is_relevant(
mode_parameter,
base_input,
possible_source_characters,
true,
true,
)
};
// We know that just step max sometimes needs to be set to a sensible default (= step min)
// and we know that step size and speed is mutually exclusive and therefore doesn't need
// to be handled separately.
let step_size_max_is_relevant = is_relevant(ModeParameter::StepSizeMax);
let step_factor_max_is_relevant = is_relevant(ModeParameter::StepFactorMax);
Mode::new(ModeSettings {
absolute_mode: if is_relevant(ModeParameter::AbsoluteMode) {
self.absolute_mode
} else {
AbsoluteMode::default()
},
source_value_interval: if is_relevant(ModeParameter::SourceMinMax) {
self.source_value_interval
} else {
full_unit_interval()
},
discrete_source_value_interval: if is_relevant(ModeParameter::SourceMinMax) {
// TODO-high-discrete Use dedicated discrete source interval
full_discrete_interval()
} else {
full_discrete_interval()
},
target_value_interval: if is_relevant(ModeParameter::TargetMinMax) {
self.target_value_interval
} else {
full_unit_interval()
},
discrete_target_value_interval: if is_relevant(ModeParameter::TargetMinMax) {
// TODO-high-discrete Use dedicated discrete target interval
full_discrete_interval()
} else {
full_discrete_interval()
},
step_factor_interval: Interval::new(
self.step_factor_interval.min_val(),
if step_factor_max_is_relevant {
self.step_factor_interval.max_val()
} else {
self.step_factor_interval.min_val()
},
),
step_size_interval: Interval::new_auto(
self.step_size_interval.min_val(),
if step_size_max_is_relevant {
self.step_size_interval.max_val()
} else {
self.step_size_interval.min_val()
},
),
jump_interval: if is_relevant(ModeParameter::JumpMinMax) {
self.legacy_jump_interval
.unwrap_or_else(default_jump_interval)
} else {
full_unit_interval()
},
discrete_jump_interval: if is_relevant(ModeParameter::JumpMinMax) {
// TODO-high-discrete Use dedicated discrete jump interval
full_discrete_interval()
} else {
full_discrete_interval()
},
fire_mode: if is_relevant(ModeParameter::FireMode) {
self.fire_mode
} else {
FireMode::default()
},
press_duration_interval: self.press_duration_interval,
turbo_rate: self.turbo_rate,
takeover_mode: if is_relevant(ModeParameter::TakeoverMode) {
self.takeover_mode
} else {
TakeoverMode::default()
},
encoder_usage: if is_relevant(ModeParameter::RelativeFilter) {
self.encoder_usage
} else {
EncoderUsage::default()
},
button_usage: if is_relevant(ModeParameter::ButtonFilter) {
self.button_usage
} else {
ButtonUsage::default()
},
reverse: if is_relevant(ModeParameter::Reverse) {
self.reverse
} else {
false
},
rotate: if is_relevant(ModeParameter::Rotate) {
self.rotate
} else {
false
},
round_target_value: if is_relevant(ModeParameter::RoundTargetValue) {
self.round_target_value
} else {
false
},
out_of_range_behavior: if is_relevant(ModeParameter::OutOfRangeBehavior) {
self.out_of_range_behavior
} else {
OutOfRangeBehavior::default()
},
control_transformation: if is_relevant(ModeParameter::ControlTransformation) {
EelTransformation::compile_for_control(&self.eel_control_transformation).ok()
} else {
None
},
feedback_transformation: if is_relevant(ModeParameter::FeedbackTransformation) {
EelTransformation::compile_for_feedback(&self.eel_feedback_transformation).ok()
} else {
None
},
feedback_value_table: self.feedback_value_table.as_ref().map(|t| match t {
FeedbackValueTable::FromTextToDiscrete(v) => {
helgoboss_learn::FeedbackValueTable::FromTextToDiscrete(
clone_to_other_hash_map(&v.value),
)
}
FeedbackValueTable::FromTextToContinuous(v) => {
helgoboss_learn::FeedbackValueTable::FromTextToContinuous(
clone_to_other_hash_map(&v.value),
)
}
}),
make_absolute: if is_relevant(ModeParameter::MakeAbsolute) {
self.make_absolute
} else {
false
},
// TODO-high-discrete Use discrete IF both source and target support it AND enabled
use_discrete_processing: false,
target_value_sequence: if is_relevant(ModeParameter::TargetValueSequence) {
self.target_value_sequence.clone()
} else {
Default::default()
},
feedback_processor: match self.feedback_type {
FeedbackType::Numeric => FeedbackProcessor::Numeric,
FeedbackType::Text => FeedbackProcessor::Text {
expression: self.textual_feedback_expression.to_owned(),
},
FeedbackType::Dynamic => {
let lua = unsafe { Backbone::main_thread_lua() };
match LuaFeedbackScript::compile(lua, &self.textual_feedback_expression) {
Ok(script) => FeedbackProcessor::Dynamic {
script: CloneAsDefault::new(Some(script)),
},
Err(_) => FeedbackProcessor::Text {
expression: " ".to_string(),
},
}
}
},
feedback_color: self.feedback_color.clone(),
feedback_background_color: self.feedback_background_color.clone(),
})
}
}
fn default_jump_interval() -> Interval<UnitValue> {
create_unit_value_interval(0.0, 0.03)
}
@@ -0,0 +1,251 @@
use base::default_util::is_default;
use num_enum::{IntoPrimitive, TryFromPrimitive};
use serde::{Deserialize, Serialize};
use derive_more::Display;
use reaper_high::{Fx, FxInfo};
use reaper_medium::ReaperStr;
use std::fmt;
use std::fmt::Formatter;
use strum::EnumIter;
pub trait PresetLinkManager: fmt::Debug {
fn find_preset_linked_to_fx(&self, fx_id: &FxId) -> Option<String>;
}
pub trait PresetLinkMutator {
fn update_fx_id(&mut self, old_fx_id: FxId, new_fx_id: FxId);
fn remove_link(&mut self, fx_id: &FxId);
fn link_preset_to_fx(&mut self, preset_id: String, fx_id: FxId);
}
#[derive(Clone, Eq, PartialEq, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FxPresetLinkConfig {
links: Vec<FxPresetLink>,
}
impl PresetLinkManager for FxPresetLinkConfig {
fn find_preset_linked_to_fx(&self, fx_id: &FxId) -> Option<String> {
// Let the links with preset name have precedence.
find_match(
self.links.iter().filter(|l| l.fx_id.has_preset_name()),
fx_id,
)
.or_else(|| {
find_match(
self.links.iter().filter(|l| !l.fx_id.has_preset_name()),
fx_id,
)
})
}
}
impl PresetLinkMutator for FxPresetLinkConfig {
fn update_fx_id(&mut self, old_fx_id: FxId, new_fx_id: FxId) {
for link in &mut self.links {
if link.fx_id == old_fx_id {
link.fx_id = new_fx_id;
return;
}
}
}
fn remove_link(&mut self, fx_id: &FxId) {
self.links.retain(|l| &l.fx_id != fx_id);
}
fn link_preset_to_fx(&mut self, preset_id: String, fx_id: FxId) {
let link = FxPresetLink { fx_id, preset_id };
if let Some(l) = self.links.iter_mut().find(|l| l.fx_id == link.fx_id) {
*l = link;
} else {
self.links.push(link);
}
}
}
impl FxPresetLinkConfig {
pub fn links(&self) -> impl ExactSizeIterator<Item = &FxPresetLink> + '_ {
self.links.iter()
}
}
#[derive(Clone, Eq, PartialEq, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FxPresetLink {
#[serde(rename = "fx")]
pub fx_id: FxId,
#[serde(rename = "presetId")]
pub preset_id: String,
}
#[derive(Clone, Eq, PartialEq, Hash, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FxId {
#[serde(default, skip_serializing_if = "is_default")]
pub name: String,
#[serde(default, skip_serializing_if = "is_default")]
pub file_name: String,
#[serde(default, skip_serializing_if = "is_default")]
pub preset_name: String,
}
impl fmt::Display for FxId {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
fn dash_if_empty(s: &str) -> &str {
if s.is_empty() {
"-"
} else {
s
}
}
write!(
f,
"Name: {} | File: {} | Preset: {}",
dash_if_empty(&self.name),
dash_if_empty(&self.file_name),
dash_if_empty(&self.preset_name)
)
}
}
impl FxId {
pub fn from_fx(fx: &Fx, most_relevant_only: bool) -> anyhow::Result<FxId> {
let fx_info = fx.info()?;
let preset_name = fx.preset_name();
let fx_id = Self::from_fx_info_and_preset_name(
&fx_info,
preset_name.as_deref(),
most_relevant_only,
);
Ok(fx_id)
}
pub fn from_fx_info_and_preset_name(
fx_info: &FxInfo,
preset_name: Option<&ReaperStr>,
most_relevant_only: bool,
) -> FxId {
let mut fx_id = FxId {
name: fx_info.effect_name.trim().to_string(),
..Default::default()
};
if !fx_id.name.is_empty() && most_relevant_only {
return fx_id;
}
fx_id.file_name = fx_info.file_name.to_string_lossy().trim().to_string();
if !fx_id.file_name.is_empty() && most_relevant_only {
return fx_id;
}
fx_id.preset_name = preset_name
.map(|s| s.to_str().trim().to_string())
.unwrap_or_default();
fx_id
}
pub fn name(&self) -> &str {
&self.name
}
pub fn has_name(&self) -> bool {
!self.name.is_empty()
}
pub fn file_name(&self) -> &str {
&self.file_name
}
pub fn has_file_name(&self) -> bool {
!self.file_name.is_empty()
}
pub fn preset_name(&self) -> &str {
&self.preset_name
}
pub fn has_preset_name(&self) -> bool {
!self.preset_name.is_empty()
}
/// Every field in the pattern that's filled must match!
///
/// The pattern FX ID fields can contain wildcards.
pub fn matches(&self, fx_id_pattern: &FxId) -> bool {
if fx_id_pattern.has_name() && !self.name_matches(fx_id_pattern) {
return false;
}
if fx_id_pattern.has_file_name() && !self.file_name_matches(fx_id_pattern) {
return false;
}
if fx_id_pattern.has_preset_name() && !self.preset_name_matches(fx_id_pattern) {
return false;
}
true
}
fn name_matches(&self, fx_id_pattern: &FxId) -> bool {
let wild_match = wildmatch::WildMatch::new(&fx_id_pattern.name);
wild_match.matches(self.name())
}
fn file_name_matches(&self, fx_id_pattern: &FxId) -> bool {
let wild_match = wildmatch::WildMatch::new(&fx_id_pattern.file_name);
wild_match.matches(self.file_name())
}
fn preset_name_matches(&self, fx_id_pattern: &FxId) -> bool {
let wild_match = wildmatch::WildMatch::new(&fx_id_pattern.preset_name);
wild_match.matches(self.preset_name())
}
}
#[derive(
Copy,
Clone,
Eq,
PartialEq,
Debug,
Serialize,
Deserialize,
EnumIter,
TryFromPrimitive,
IntoPrimitive,
Display,
)]
#[repr(usize)]
pub enum AutoLoadMode {
#[serde(rename = "off")]
#[display(fmt = "Off")]
Off,
#[serde(rename = "focused-fx")]
#[display(fmt = "Based on unit FX")]
UnitFx,
}
impl Default for AutoLoadMode {
fn default() -> Self {
Self::Off
}
}
impl AutoLoadMode {
pub fn is_on(&self) -> bool {
*self != Self::Off
}
}
fn find_match<'a>(
mut links: impl Iterator<Item = &'a FxPresetLink>,
fx_id: &FxId,
) -> Option<String> {
links.find_map(|link| {
if fx_id.matches(&link.fx_id) {
Some(link.preset_id.clone())
} else {
None
}
})
}
@@ -0,0 +1,108 @@
/// A type which can express what properties are potentially be affected by a change operation.
#[derive(Eq, PartialEq)]
pub enum Affected<T> {
/// Just the given property might be affected.
One(T),
/// Multiple properties might be affected.
Multiple,
}
impl<T> Affected<T> {
pub fn processing_relevance(&self) -> Option<ProcessingRelevance>
where
T: GetProcessingRelevance,
{
use Affected::*;
match self {
One(p) => p.processing_relevance(),
Multiple => Some(ProcessingRelevance::ProcessingRelevant),
}
}
}
/// Defines how relevant a change to a model object is for the processing logic.
///
/// Depending on this value, the session will decide whether to sync data to the processing layer
/// or not.
#[derive(Eq, PartialEq, Ord, PartialOrd)]
pub enum ProcessingRelevance {
/// Lowest relevance level: Syncing of persistent processing state necessary.
///
/// Returned if a change of the given prop would have an effect on control/feedback
/// processing and is also changed by the processing layer itself, so it shouldn't contain much!
/// The session takes care to not sync the complete mapping properties but only the ones
/// mentioned here.
//
// Important to keep this on top! Order matters.
PersistentProcessingRelevant,
/// Highest relevance level: Syncing of complete mapping state necessary.
///
/// Returned if this is a property that has an effect on control/feedback processing.
///
/// However, we don't include properties here which are changed by the processing layer
/// (such as `is_enabled`) because that would mean the complete mapping will be synced as a
/// result, whereas we want to sync processing stuff faster!
ProcessingRelevant,
}
pub type ChangeResult<T> = Result<Option<Affected<T>>, String>;
/// Usable for changing values of properties in an infallible way.
///
/// This is a bit like the Flux pattern. One has commands (or actions) that describe how to change
/// state and the store (in this case the value itself) changes its state accordingly.
///
/// This pattern has been introduced in #492 when changing the change-notification mechanism.
/// We moved from an Rx-based approach where each property has its own subscribers to a more
/// flexible and much more memory-friendly approach that works by letting any property change start
/// at the session and letting the session handle the notification centrally.
///
/// This command pattern is actually not necessary for this new change-notification mechanism. We
/// could also provide simple setter methods that return `Affected` values (as we already do when
/// we apply more complex changes than just changing a few properties). This would have the
/// advantage that we can choose more specific return types, not such a generic one (e.g. `Result`
/// if the change is fallible). However, we introduced the pattern and it's too early to remove it.
///
/// Because it also has some potential advantages:
///
/// - It unifies infallible property write access.
/// - It allows for hierarchical changes without the need for closures, e.g. the command to change a
/// target property p of a mapping m in compartment c is expressed as a simple object! This also
/// has a nice symmetry to the way affected properties are returned (which is also hierarchial).
/// - Because of this unification, we could record changes, log them easily, even build some undo
/// system on it.
///
/// Let's see where this goes!
pub trait Change<'a> {
type Command;
type Prop;
fn change(&mut self, cmd: Self::Command) -> Option<Affected<Self::Prop>>;
}
pub trait GetProcessingRelevance {
fn processing_relevance(&self) -> Option<ProcessingRelevance>;
}
pub fn merge_affected<T: PartialEq>(
affected_1: Option<Affected<T>>,
affected_2: Option<Affected<T>>,
) -> Option<Affected<T>> {
match (affected_1, affected_2) {
(None, None) => None,
(None, Some(a)) | (Some(a), None) => Some(a),
(Some(a), Some(b)) => {
use Affected::*;
match (a, b) {
(_, Multiple) | (Multiple, _) => Some(Multiple),
(One(p1), One(p2)) => {
if p1 == p2 {
Some(One(p1))
} else {
Some(Multiple)
}
}
}
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,54 @@
use base::metrics_util::record_occurrence;
use helgobox_allocator::{AsyncDeallocationIntegration, Deallocate, HelgobossAllocator};
use std::alloc::{GlobalAlloc, Layout, System};
#[global_allocator]
pub static GLOBAL_ALLOCATOR: HelgobossAllocator<RealearnAllocatorIntegration, RealearnDeallocator> =
HelgobossAllocator::new(RealearnDeallocator::without_metrics());
/// Allocator integration which defers deallocation when in real-time thread.
pub struct RealearnAllocatorIntegration {
is_in_real_time_audio: IsInRealTimeAudio,
}
pub type IsInRealTimeAudio = extern "C" fn() -> ::std::os::raw::c_int;
impl RealearnAllocatorIntegration {
pub fn new(is_in_real_time_audio: IsInRealTimeAudio) -> Self {
Self {
is_in_real_time_audio,
}
}
}
impl AsyncDeallocationIntegration for RealearnAllocatorIntegration {
fn offload_deallocation(&self) -> bool {
// Defer deallocation whenever we are in a real-time audio thread.
(self.is_in_real_time_audio)() != 0
}
}
pub struct RealearnDeallocator {
metric_id: Option<&'static str>,
}
impl RealearnDeallocator {
pub const fn without_metrics() -> Self {
Self { metric_id: None }
}
pub const fn with_metrics(metric_id: &'static str) -> Self {
Self {
metric_id: Some(metric_id),
}
}
}
impl Deallocate for RealearnDeallocator {
fn deallocate(&self, ptr: *mut u8, layout: Layout) {
if let Some(id) = self.metric_id {
record_occurrence(id);
}
unsafe { System.dealloc(ptr, layout) };
}
}
@@ -0,0 +1,423 @@
/* automatically generated by rust-bindgen 0.69.2 */
#![allow(non_upper_case_globals)]
#![allow(non_camel_case_types)]
#![allow(non_snake_case)]
#![allow(dead_code)]
#![allow(deref_nullptr)]
#[allow(non_snake_case, non_camel_case_types, non_upper_case_globals)]
pub mod root {
#[allow(unused_imports)]
use self::super::root;
pub const NSEEL_CODE_COMPILE_FLAG_COMMONFUNCS: u32 = 1;
pub const NSEEL_CODE_COMPILE_FLAG_COMMONFUNCS_RESET: u32 = 2;
pub const NSEEL_CODE_COMPILE_FLAG_NOFPSTATE: u32 = 4;
pub const NSEEL_CODE_COMPILE_FLAG_ONLY_BUILTIN_FUNCTIONS: u32 = 8;
pub const NSEEL_MAX_VARIABLE_NAMELEN: u32 = 128;
pub const NSEEL_MAX_EELFUNC_PARAMETERS: u32 = 40;
pub const NSEEL_MAX_FUNCSIG_NAME: u32 = 2048;
pub const NSEEL_LOOPFUNC_SUPPORT_MAXLEN: u32 = 1048576;
pub const NSEEL_MAX_FUNCTION_SIZE_FOR_INLINE: u32 = 2048;
pub const NSEEL_SHARED_GRAM_SIZE: u32 = 1048576;
pub const NSEEL_RAM_BLOCKS_DEFAULTMAX: u32 = 128;
pub const NSEEL_RAM_BLOCKS_LOG2: u32 = 9;
pub const NSEEL_RAM_ITEMSPERBLOCK_LOG2: u32 = 16;
pub const NSEEL_RAM_BLOCKS: u32 = 512;
pub const NSEEL_RAM_ITEMSPERBLOCK: u32 = 65536;
pub const NSEEL_STACK_SIZE: u32 = 4096;
pub mod std {
#[allow(unused_imports)]
use self::super::super::root;
}
pub type INT_PTR = isize;
pub type EEL_F = f64;
extern "C" {
pub fn NSEEL_HOSTSTUB_EnterMutex();
}
extern "C" {
pub fn NSEEL_HOSTSTUB_LeaveMutex();
}
extern "C" {
pub fn NSEEL_init() -> ::std::os::raw::c_int;
}
extern "C" {
pub fn NSEEL_quit();
}
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct functionType {
_unused: [u8; 0],
}
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct eel_function_table {
pub list: *mut root::functionType,
pub list_size: ::std::os::raw::c_int,
}
#[test]
fn bindgen_test_layout_eel_function_table() {
const UNINIT: ::std::mem::MaybeUninit<eel_function_table> =
::std::mem::MaybeUninit::uninit();
let ptr = UNINIT.as_ptr();
assert_eq!(
::std::mem::size_of::<eel_function_table>(),
16usize,
concat!("Size of: ", stringify!(eel_function_table))
);
assert_eq!(
::std::mem::align_of::<eel_function_table>(),
8usize,
concat!("Alignment of ", stringify!(eel_function_table))
);
assert_eq!(
unsafe { ::std::ptr::addr_of!((*ptr).list) as usize - ptr as usize },
0usize,
concat!(
"Offset of field: ",
stringify!(eel_function_table),
"::",
stringify!(list)
)
);
assert_eq!(
unsafe { ::std::ptr::addr_of!((*ptr).list_size) as usize - ptr as usize },
8usize,
concat!(
"Offset of field: ",
stringify!(eel_function_table),
"::",
stringify!(list_size)
)
);
}
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct _compileContext {
_unused: [u8; 0],
}
pub type NSEEL_PPPROC = ::std::option::Option<
unsafe extern "C" fn(
data: *mut ::std::os::raw::c_void,
data_size: ::std::os::raw::c_int,
userfunc_data: *mut root::_compileContext,
) -> *mut ::std::os::raw::c_void,
>;
extern "C" {
pub fn NSEEL_addfunctionex2(
name: *const ::std::os::raw::c_char,
nparms: ::std::os::raw::c_int,
code_startaddr: *mut ::std::os::raw::c_char,
code_len: ::std::os::raw::c_int,
pproc: root::NSEEL_PPPROC,
fptr: *mut ::std::os::raw::c_void,
fptr2: *mut ::std::os::raw::c_void,
destination: *mut root::eel_function_table,
);
}
extern "C" {
pub fn NSEEL_addfunc_ret_type(
name: *const ::std::os::raw::c_char,
np: ::std::os::raw::c_int,
ret_type: ::std::os::raw::c_int,
pproc: root::NSEEL_PPPROC,
fptr: *mut ::std::os::raw::c_void,
destination: *mut root::eel_function_table,
);
}
extern "C" {
pub fn NSEEL_addfunc_varparm_ex(
name: *const ::std::os::raw::c_char,
min_np: ::std::os::raw::c_int,
want_exact: ::std::os::raw::c_int,
pproc: root::NSEEL_PPPROC,
fptr: ::std::option::Option<
unsafe extern "C" fn(
arg1: *mut ::std::os::raw::c_void,
arg2: root::INT_PTR,
arg3: *mut *mut root::EEL_F,
) -> root::EEL_F,
>,
destination: *mut root::eel_function_table,
);
}
extern "C" {
pub fn NSEEL_addfunc_varparm_ctxptr(
name: *const ::std::os::raw::c_char,
min_np: ::std::os::raw::c_int,
want_exact: ::std::os::raw::c_int,
ctxptr: *mut ::std::os::raw::c_void,
fptr: ::std::option::Option<
unsafe extern "C" fn(
arg1: *mut ::std::os::raw::c_void,
arg2: root::INT_PTR,
arg3: *mut *mut root::EEL_F,
) -> root::EEL_F,
>,
destination: *mut root::eel_function_table,
);
}
extern "C" {
pub fn NSEEL_addfunc_varparm_ctxptr2(
name: *const ::std::os::raw::c_char,
min_np: ::std::os::raw::c_int,
want_exact: ::std::os::raw::c_int,
pproc: root::NSEEL_PPPROC,
ctx: *mut ::std::os::raw::c_void,
fptr: ::std::option::Option<
unsafe extern "C" fn(
arg1: *mut ::std::os::raw::c_void,
arg2: *mut ::std::os::raw::c_void,
arg3: root::INT_PTR,
arg4: *mut *mut root::EEL_F,
) -> root::EEL_F,
>,
destination: *mut root::eel_function_table,
);
}
extern "C" {
pub fn NSEEL_getstats() -> *mut ::std::os::raw::c_int;
}
pub type NSEEL_VMCTX = *mut ::std::os::raw::c_void;
pub type NSEEL_CODEHANDLE = *mut ::std::os::raw::c_void;
extern "C" {
pub fn NSEEL_VM_alloc() -> root::NSEEL_VMCTX;
}
extern "C" {
pub fn NSEEL_VM_free(ctx: root::NSEEL_VMCTX);
}
extern "C" {
pub fn NSEEL_VM_SetFunctionTable(
arg1: root::NSEEL_VMCTX,
tab: *mut root::eel_function_table,
);
}
extern "C" {
pub fn NSEEL_VM_SetFunctionValidator(
arg1: root::NSEEL_VMCTX,
validateFunc: ::std::option::Option<
unsafe extern "C" fn(
fn_name: *const ::std::os::raw::c_char,
user: *mut ::std::os::raw::c_void,
) -> *const ::std::os::raw::c_char,
>,
user: *mut ::std::os::raw::c_void,
);
}
extern "C" {
pub fn NSEEL_VM_remove_unused_vars(_ctx: root::NSEEL_VMCTX);
}
extern "C" {
pub fn NSEEL_VM_clear_var_refcnts(_ctx: root::NSEEL_VMCTX);
}
extern "C" {
pub fn NSEEL_VM_remove_all_nonreg_vars(_ctx: root::NSEEL_VMCTX);
}
extern "C" {
pub fn NSEEL_VM_enumallvars(
ctx: root::NSEEL_VMCTX,
func: ::std::option::Option<
unsafe extern "C" fn(
name: *const ::std::os::raw::c_char,
val: *mut root::EEL_F,
ctx: *mut ::std::os::raw::c_void,
) -> ::std::os::raw::c_int,
>,
userctx: *mut ::std::os::raw::c_void,
);
}
extern "C" {
pub fn NSEEL_VM_regvar(
ctx: root::NSEEL_VMCTX,
name: *const ::std::os::raw::c_char,
) -> *mut root::EEL_F;
}
extern "C" {
pub fn NSEEL_VM_getvar(
ctx: root::NSEEL_VMCTX,
name: *const ::std::os::raw::c_char,
) -> *mut root::EEL_F;
}
extern "C" {
pub fn NSEEL_VM_get_var_refcnt(
_ctx: root::NSEEL_VMCTX,
name: *const ::std::os::raw::c_char,
) -> ::std::os::raw::c_int;
}
extern "C" {
pub fn NSEEL_VM_set_var_resolver(
ctx: root::NSEEL_VMCTX,
res: ::std::option::Option<
unsafe extern "C" fn(
userctx: *mut ::std::os::raw::c_void,
name: *const ::std::os::raw::c_char,
) -> *mut root::EEL_F,
>,
userctx: *mut ::std::os::raw::c_void,
);
}
extern "C" {
pub fn NSEEL_VM_freeRAM(ctx: root::NSEEL_VMCTX);
}
extern "C" {
pub fn NSEEL_VM_freeRAMIfCodeRequested(arg1: root::NSEEL_VMCTX);
}
extern "C" {
pub fn NSEEL_VM_wantfreeRAM(ctx: root::NSEEL_VMCTX) -> ::std::os::raw::c_int;
}
extern "C" {
pub fn NSEEL_VM_SetGRAM(ctx: root::NSEEL_VMCTX, gram: *mut *mut ::std::os::raw::c_void);
}
extern "C" {
pub fn NSEEL_VM_FreeGRAM(ufd: *mut *mut ::std::os::raw::c_void);
}
extern "C" {
pub fn NSEEL_VM_SetCustomFuncThis(
ctx: root::NSEEL_VMCTX,
thisptr: *mut ::std::os::raw::c_void,
);
}
extern "C" {
pub fn NSEEL_VM_getramptr(
ctx: root::NSEEL_VMCTX,
offs: ::std::os::raw::c_uint,
validCount: *mut ::std::os::raw::c_int,
) -> *mut root::EEL_F;
}
extern "C" {
pub fn NSEEL_VM_getramptr_noalloc(
ctx: root::NSEEL_VMCTX,
offs: ::std::os::raw::c_uint,
validCount: *mut ::std::os::raw::c_int,
) -> *mut root::EEL_F;
}
extern "C" {
pub fn NSEEL_VM_setramsize(
ctx: root::NSEEL_VMCTX,
maxent: ::std::os::raw::c_int,
) -> ::std::os::raw::c_int;
}
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct eelStringSegmentRec {
pub _next: *mut root::eelStringSegmentRec,
pub str_start: *const ::std::os::raw::c_char,
pub str_len: ::std::os::raw::c_int,
}
#[test]
fn bindgen_test_layout_eelStringSegmentRec() {
const UNINIT: ::std::mem::MaybeUninit<eelStringSegmentRec> =
::std::mem::MaybeUninit::uninit();
let ptr = UNINIT.as_ptr();
assert_eq!(
::std::mem::size_of::<eelStringSegmentRec>(),
24usize,
concat!("Size of: ", stringify!(eelStringSegmentRec))
);
assert_eq!(
::std::mem::align_of::<eelStringSegmentRec>(),
8usize,
concat!("Alignment of ", stringify!(eelStringSegmentRec))
);
assert_eq!(
unsafe { ::std::ptr::addr_of!((*ptr)._next) as usize - ptr as usize },
0usize,
concat!(
"Offset of field: ",
stringify!(eelStringSegmentRec),
"::",
stringify!(_next)
)
);
assert_eq!(
unsafe { ::std::ptr::addr_of!((*ptr).str_start) as usize - ptr as usize },
8usize,
concat!(
"Offset of field: ",
stringify!(eelStringSegmentRec),
"::",
stringify!(str_start)
)
);
assert_eq!(
unsafe { ::std::ptr::addr_of!((*ptr).str_len) as usize - ptr as usize },
16usize,
concat!(
"Offset of field: ",
stringify!(eelStringSegmentRec),
"::",
stringify!(str_len)
)
);
}
extern "C" {
pub fn NSEEL_VM_SetStringFunc(
ctx: root::NSEEL_VMCTX,
onString: ::std::option::Option<
unsafe extern "C" fn(
caller_this: *mut ::std::os::raw::c_void,
list: *mut root::eelStringSegmentRec,
) -> root::EEL_F,
>,
onNamedString: ::std::option::Option<
unsafe extern "C" fn(
caller_this: *mut ::std::os::raw::c_void,
name: *const ::std::os::raw::c_char,
) -> root::EEL_F,
>,
);
}
extern "C" {
pub fn NSEEL_code_compile(
ctx: root::NSEEL_VMCTX,
code: *const ::std::os::raw::c_char,
lineoffs: ::std::os::raw::c_int,
) -> root::NSEEL_CODEHANDLE;
}
extern "C" {
pub fn NSEEL_code_compile_ex(
ctx: root::NSEEL_VMCTX,
code: *const ::std::os::raw::c_char,
lineoffs: ::std::os::raw::c_int,
flags: ::std::os::raw::c_int,
) -> root::NSEEL_CODEHANDLE;
}
extern "C" {
pub fn NSEEL_code_getcodeerror(ctx: root::NSEEL_VMCTX) -> *mut ::std::os::raw::c_char;
}
extern "C" {
pub fn NSEEL_code_geterror_flag(ctx: root::NSEEL_VMCTX) -> ::std::os::raw::c_int;
}
extern "C" {
pub fn NSEEL_code_execute(code: root::NSEEL_CODEHANDLE);
}
extern "C" {
pub fn NSEEL_code_free(code: root::NSEEL_CODEHANDLE);
}
extern "C" {
pub fn NSEEL_code_getstats(code: root::NSEEL_CODEHANDLE) -> *mut ::std::os::raw::c_int;
}
extern "C" {
pub static mut NSEEL_RAM_limitmem: ::std::os::raw::c_uint;
}
extern "C" {
pub static mut NSEEL_RAM_memused: ::std::os::raw::c_uint;
}
extern "C" {
pub static mut NSEEL_RAM_memused_errors: ::std::os::raw::c_int;
}
extern "C" {
pub fn NSEEL_PProc_RAM(
data: *mut ::std::os::raw::c_void,
data_size: ::std::os::raw::c_int,
ctx: *mut root::_compileContext,
) -> *mut ::std::os::raw::c_void;
}
extern "C" {
pub fn NSEEL_PProc_THIS(
data: *mut ::std::os::raw::c_void,
data_size: ::std::os::raw::c_int,
ctx: *mut root::_compileContext,
) -> *mut ::std::os::raw::c_void;
}
}
@@ -0,0 +1,33 @@
/// A wrapper that implements Clone but not really by cloning the wrapped value but by
/// creating the inner type's default value.
///
/// This is useful if you have a large nested value of which almost anything inside must be cloned
/// but you also have a few values in there that don't suit themselves to be cloned (e.g. compiled
/// scripts). This must be well documented though, it's a very surprising behavior.
///
/// Alternatives to be considered before reaching out to this:
///
/// - Making the whole graph not cloneable
/// - Using `Rc` or `Arc` (is cloneable but means that the inner value is not "standalone" anymore,
/// can be accessed from multiple places or in case of `Arc` even threads ... and it means that
/// you have less control over when and in which thread deallocation happens).
/// - Writing a dedicated method (not `clone`) which makes it clear that this is not a standard
/// clone operation.
#[derive(Debug)]
pub struct CloneAsDefault<T>(T);
impl<T> CloneAsDefault<T> {
pub fn new(value: T) -> Self {
Self(value)
}
pub fn get(&self) -> &T {
&self.0
}
}
impl<T: Default> Clone for CloneAsDefault<T> {
fn clone(&self) -> Self {
Self(T::default())
}
}
+212
View File
@@ -0,0 +1,212 @@
use crate::base::allocator::GLOBAL_ALLOCATOR;
use crate::base::bindings::root;
use crate::base::bindings::root::eel_function_table;
use reaper_medium::ReaperStr;
use std::ffi::{CStr, CString};
use std::mem::MaybeUninit;
use std::os::raw::c_void;
use std::ptr::null_mut;
#[derive(Debug)]
pub struct Vm {
vm_ctx: root::NSEEL_VMCTX,
function_table: Box<root::eel_function_table>,
}
unsafe impl Send for Vm {}
#[derive(Debug)]
pub struct Program(root::NSEEL_CODEHANDLE);
unsafe impl Send for Program {}
#[derive(Copy, Clone, Debug)]
pub struct Variable(*mut f64);
unsafe impl Send for Variable {}
// TODO-medium It's actually not Sync. It's safe in our case because we know that we never use an
// EEL program at the same time in 2 threads.
unsafe impl Sync for Vm {}
unsafe impl Sync for Program {}
unsafe impl Sync for Variable {}
impl Vm {
pub fn new() -> Vm {
let vm_ctx = unsafe { root::NSEEL_VM_alloc() };
let mut function_table = Box::new(eel_function_table {
list: null_mut(),
list_size: 0,
});
unsafe {
root::NSEEL_VM_SetFunctionTable(vm_ctx, &mut *function_table as *mut _);
}
Vm {
vm_ctx,
function_table,
}
}
pub fn register_single_arg_function(
&mut self,
name: &'static ReaperStr,
f: unsafe extern "C" fn(opaque: *mut c_void, amt: *mut f64) -> f64,
) {
unsafe {
root::NSEEL_addfunc_ret_type(
name.as_c_str().as_ptr(),
1,
// Return double
1,
Some(root::NSEEL_PProc_THIS),
f as *mut c_void,
&mut *self.function_table as *mut _,
);
}
}
pub fn register_void_or_bool_function(
&mut self,
name: &'static ReaperStr,
f: unsafe extern "C" fn(opaque: *mut c_void, amt: *mut f64) -> bool,
) {
unsafe {
root::NSEEL_addfunc_ret_type(
name.as_c_str().as_ptr(),
1,
// Return void or bool
-1,
Some(root::NSEEL_PProc_THIS),
f as *mut c_void,
&mut *self.function_table as *mut _,
);
}
}
pub fn register_variable(&self, name: &str) -> Variable {
let c_string = CString::new(name).expect("variable name is not valid UTF-8");
let ptr = unsafe { root::NSEEL_VM_regvar(self.vm_ctx, c_string.as_ptr()) };
Variable(ptr)
}
pub fn register_and_set_variable(&self, name: &str, value: f64) -> Variable {
let v = self.register_variable(name);
unsafe {
v.set(value);
}
v
}
pub fn get_mem_slice(&self, index: u32, size: u32) -> &[f64] {
let mut valid_count = MaybeUninit::zeroed();
let ptr = unsafe {
root::NSEEL_VM_getramptr_noalloc(self.vm_ctx, index, valid_count.as_mut_ptr())
};
let valid_count = unsafe { valid_count.assume_init() };
if ptr.is_null() || valid_count <= 0 {
return &[];
}
let slice_len = std::cmp::min(valid_count as u32, size);
let slice = std::ptr::slice_from_raw_parts(ptr, slice_len as _);
unsafe { &*slice }
}
pub fn compile(&self, code: &str) -> Result<Program, String> {
if code.trim().is_empty() {
return Err("Empty".to_owned());
}
let c_string = CString::new(code).map_err(|_| "Code is not valid UTF-8")?;
let code_handle = unsafe { root::NSEEL_code_compile(self.vm_ctx, c_string.as_ptr(), 0) };
if code_handle.is_null() {
let error = unsafe { root::NSEEL_code_getcodeerror(self.vm_ctx) };
if error.is_null() {
return Err("Unknown error".to_string());
}
let c_str = unsafe { CStr::from_ptr(error) };
let string = c_str
.to_owned()
.into_string()
.unwrap_or_else(|_| "Couldn't convert error to string".to_string());
return Err(string);
}
Ok(Program(code_handle))
}
}
impl Program {
pub unsafe fn execute(&self) {
root::NSEEL_code_execute(self.0);
}
}
impl Variable {
pub unsafe fn get(&self) -> f64 {
*self.0
}
pub unsafe fn set(&self, value: f64) {
*self.0 = value;
}
}
impl Drop for Vm {
fn drop(&mut self) {
GLOBAL_ALLOCATOR.dealloc_foreign_value(root::NSEEL_VM_free, self.vm_ctx);
}
}
impl Drop for Program {
fn drop(&mut self) {
GLOBAL_ALLOCATOR.dealloc_foreign_value(root::NSEEL_code_free, self.0);
}
}
#[no_mangle]
extern "C" fn NSEEL_HOSTSTUB_EnterMutex() {}
#[no_mangle]
extern "C" fn NSEEL_HOSTSTUB_LeaveMutex() {}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_abs_diff_eq;
#[test]
fn basics() {
// Given
let vm = Vm::new();
let x = vm.register_variable("x");
let y = vm.register_variable("y");
let program = vm.compile("y = x + 1;").expect("couldn't compile");
// // When
let y_result = unsafe {
x.set(42.0);
y.set(0.0);
program.execute();
y.get()
};
// Then
assert_abs_diff_eq!(y_result, 43.0);
}
#[test]
fn get_mem_slice() {
// Given
let vm = Vm::new();
let x = vm.register_variable("x");
let program = vm
.compile("y[0] = x + 1; y[1] = x + 2; y[2] = x + 5")
.expect("couldn't compile");
// // When
let slice = unsafe {
x.set(42.0);
program.execute();
vm.get_mem_slice(0, 3)
};
// Then
assert_abs_diff_eq!(slice[0], 43.0);
assert_abs_diff_eq!(slice[1], 44.0);
assert_abs_diff_eq!(slice[2], 47.0);
}
}
@@ -0,0 +1,16 @@
mod scheduling;
pub use scheduling::*;
mod property;
pub use property::*;
pub mod notification;
pub mod eel;
pub mod bindings;
mod clone_as_default;
pub use clone_as_default::*;
pub mod allocator;
@@ -0,0 +1,58 @@
use reaper_high::Reaper;
use reaper_medium::{MessageBoxType, ReaperStringArg};
use std::error::Error;
pub fn notify_processing_result(heading: &str, msgs: Vec<String>) {
let joined_msg = msgs.join("\n\n");
let msg = format!(
"{}\n{}\n\n{}\n\n",
heading,
"-".repeat(heading.len()),
joined_msg
);
Reaper::get().show_console_msg(msg);
}
pub fn warn_user_on_anyhow_error(result: anyhow::Result<()>) {
if let Err(e) = result {
warn_user_about_anyhow_error(e)
}
}
pub fn warn_user_about_anyhow_error(error: anyhow::Error) {
warn(format!("{error:#}"));
}
pub fn warn(msg: String) {
Reaper::get().show_console_msg(format!("\n\nReaLearn warning: {msg} "));
}
#[allow(dead_code)]
pub fn notify_user_on_error(result: Result<(), Box<dyn Error>>) {
if let Err(e) = result {
notify_user_about_error(e);
}
}
#[allow(dead_code)]
pub fn notify_user_on_anyhow_error(result: anyhow::Result<()>) {
if let Err(e) = result {
notify_user_about_anyhow_error(&e);
}
}
#[allow(dead_code)]
pub fn notify_user_about_error(e: Box<dyn Error>) {
alert(e.to_string());
}
#[allow(dead_code)]
pub fn notify_user_about_anyhow_error(e: &anyhow::Error) {
alert(format!("{e:#}"));
}
pub fn alert<'a>(msg: impl Into<ReaperStringArg<'a>>) {
Reaper::get()
.medium_reaper()
.show_message_box(msg, "Helgobox", MessageBoxType::Okay);
}
@@ -0,0 +1,44 @@
//! In this file we assemble the a custom-tailored property type which we are going to use
//! throughout ReaLearn.
use rx_util::{LocalProp, LocalPropSubject, Notifier};
use rxrust::prelude::*;
use std::marker::PhantomData;
/// Creates a ReaLearn property.
pub fn prop<T>(initial_value: T) -> Prop<T>
where
T: PartialEq + Clone + 'static,
{
Prop::new(initial_value)
}
/// ReaLearn property type.
pub type Prop<T> = LocalProp<'static, T, u32, AsyncNotifier<Option<u32>>, AsyncNotifier<T>>;
pub struct AsyncNotifier<T>(PhantomData<T>);
impl<T> Notifier for AsyncNotifier<T>
where
T: Clone + 'static,
{
type T = T;
type Subject = LocalPropSubject<'static, T>;
fn notify(subject: &mut Self::Subject, value: &Self::T) {
if subject.subscribed_size() == 0 {
return;
}
#[cfg(test)]
subject.next(value.clone());
#[cfg(not(test))]
{
let mut subject = subject.clone();
let value = value.clone();
base::Global::task_support()
.do_later_in_main_thread_from_main_thread_asap(move || {
subject.next(value);
})
.unwrap();
}
}
}
@@ -0,0 +1,189 @@
use rxrust::prelude::*;
use base::Global;
use std::marker::PhantomData;
use std::rc::{Rc, Weak};
use tracing::debug;
pub fn when<Item, Trigger>(trigger: Trigger) -> ReactionBuilderStepOne<Item, Trigger>
where
Trigger: LocalObservable<'static, Item = Item, Err = ()> + 'static,
{
ReactionBuilderStepOne {
trigger,
p: PhantomData,
}
}
pub struct ReactionBuilderStepOne<Item, Trigger> {
trigger: Trigger,
p: PhantomData<Item>,
}
impl<Item, Trigger> ReactionBuilderStepOne<Item, Trigger>
where
Trigger: LocalObservable<'static, Item = Item, Err = ()> + 'static,
{
pub fn with<Receiver>(
self,
weak_receiver: Weak<Receiver>,
) -> ReactionBuilderStepTwo<Item, Trigger, Receiver>
where
Receiver: 'static,
{
ReactionBuilderStepTwo {
parent: self,
weak_receiver,
}
}
}
pub struct ReactionBuilderStepTwo<Item, Trigger, Receiver> {
parent: ReactionBuilderStepOne<Item, Trigger>,
weak_receiver: Weak<Receiver>,
}
impl<Item: 'static, Trigger, Receiver> ReactionBuilderStepTwo<Item, Trigger, Receiver>
where
Trigger: LocalObservable<'static, Item = Item, Err = ()> + 'static,
Receiver: 'static,
{
pub fn finally<Finalizer>(
self,
finalizer: Finalizer,
) -> ReactionBuilderStepThree<Item, Trigger, Receiver, Finalizer>
where
Finalizer: Fn(Rc<Receiver>) + 'static,
{
ReactionBuilderStepThree {
parent: self,
finalizer,
}
}
/// Executes the given reaction synchronously whenever the specified event is raised.
pub fn do_sync(
self,
reaction: impl Fn(Rc<Receiver>, Item) + 'static,
) -> SubscriptionWrapper<impl SubscriptionLike> {
Self::do_sync_internal(self.weak_receiver, self.parent.trigger, reaction)
}
/// Executes the given reaction whenever the specified event is raised.
///
/// It doesn't execute the reaction immediately but in the next main run loop cycle. That's also
/// why the "trigger" and "until" items need to be shareable (Clone + Send + Sync), which is
/// no problem in practice because they are just `()` in our case.
///
/// The observables are expected to be local (not shareable). Application of the `delay()`
/// operator would require them to be shared, too. But `delay()` doesn't work anyway right
/// now (https://github.com/rxRust/rxRust/issues/106). So there's no reason to change all the
/// trigger/until subjects/properties into shared ones.
pub fn do_async(
self,
reaction: impl Fn(Rc<Receiver>, Item) + Clone + 'static,
) -> SubscriptionWrapper<impl SubscriptionLike>
where
Item: 'static,
{
Self::do_async_internal(self.weak_receiver, self.parent.trigger, reaction)
}
fn do_sync_internal(
weak_receiver: Weak<Receiver>,
trigger: impl LocalObservable<'static, Item = Item, Err = ()> + 'static,
reaction: impl Fn(Rc<Receiver>, Item) + 'static,
) -> SubscriptionWrapper<impl SubscriptionLike> {
trigger.subscribe(move |item| {
if let Some(receiver) = upgrade(&weak_receiver) {
(reaction)(receiver, item);
}
})
}
fn do_async_internal(
weak_receiver: Weak<Receiver>,
trigger: impl LocalObservable<'static, Item = Item, Err = ()> + 'static,
reaction: impl Fn(Rc<Receiver>, Item) + Clone + 'static,
) -> SubscriptionWrapper<impl SubscriptionLike>
where
Item: 'static,
{
trigger.subscribe(move |item| {
let weak_receiver = weak_receiver.clone();
let reaction = reaction.clone();
Global::task_support()
.do_later_in_main_thread_from_main_thread_asap(move || {
if let Some(receiver) = upgrade(&weak_receiver) {
(reaction)(receiver, item);
}
})
.unwrap();
})
}
}
pub struct ReactionBuilderStepThree<Item, Trigger, Receiver, Finalizer> {
parent: ReactionBuilderStepTwo<Item, Trigger, Receiver>,
finalizer: Finalizer,
}
impl<Item, Trigger, Receiver, Finalizer>
ReactionBuilderStepThree<Item, Trigger, Receiver, Finalizer>
where
Trigger: LocalObservable<'static, Item = Item, Err = ()> + 'static,
Receiver: 'static,
Finalizer: Fn(Rc<Receiver>) + Clone + 'static,
Item: 'static,
{
pub fn do_sync(
self,
reaction: impl Fn(Rc<Receiver>, Item) + Clone + 'static,
) -> SubscriptionWrapper<impl SubscriptionLike> {
let weak_receiver = self.parent.weak_receiver.clone();
let finalizer = self.finalizer;
ReactionBuilderStepTwo::<Item, Trigger, Receiver>::do_sync_internal(
self.parent.weak_receiver,
self.parent.parent.trigger.finalize(move || {
if let Some(receiver) = upgrade(&weak_receiver) {
(finalizer)(receiver);
}
}),
reaction,
)
}
pub fn do_async(
self,
reaction: impl Fn(Rc<Receiver>, Item) + Clone + 'static,
) -> SubscriptionWrapper<impl SubscriptionLike>
where
Item: 'static,
{
let weak_receiver = self.parent.weak_receiver.clone();
let finalizer = self.finalizer;
ReactionBuilderStepTwo::<Item, Trigger, Receiver>::do_async_internal(
self.parent.weak_receiver,
self.parent.parent.trigger.finalize(move || {
let weak_receiver = weak_receiver.clone();
let finalizer = finalizer.clone();
Global::task_support()
.do_later_in_main_thread_from_main_thread_asap(move || {
if let Some(receiver) = upgrade(&weak_receiver) {
(finalizer)(receiver);
}
})
.unwrap();
}),
reaction,
)
}
}
fn upgrade<T>(weak_receiver: &Weak<T>) -> Option<Rc<T>> {
let shared_receiver = weak_receiver.upgrade();
if shared_receiver.is_none() {
debug!("Receiver gone");
}
shared_receiver
}
@@ -0,0 +1,3 @@
// EEL language support for value transformation in ReaLearn mode section
#include "../../lib/WDL/WDL/eel2/ns-eel.h"
#include "../../lib/WDL/WDL/eel2/ns-eel-addfuncs.h"
Binary file not shown.
@@ -0,0 +1,166 @@
use crate::domain::{
Backbone, ControlEvent, ControlEventTimestamp, DomainEventHandler, KeyMessage, Keystroke,
SharedMainProcessors,
};
use reaper_high::Reaper;
use reaper_low::raw;
use reaper_medium::{
virt_keys, AccelMsg, AccelMsgKind, AcceleratorBehavior, TranslateAccel, TranslateAccelArgs,
TranslateAccelResult,
};
use swell_ui::{SharedView, View, Window};
pub trait HelgoboxWindowSnitch {
/// Goes up the window hierarchy trying to find an associated ReaLearn view, starting with
/// the given window.
fn find_closest_realearn_view(&self, window: Window) -> Option<SharedView<dyn View>>;
}
#[derive(Debug)]
pub struct RealearnAccelerator<EH: DomainEventHandler, S> {
main_processors: SharedMainProcessors<EH>,
snitch: S,
}
impl<EH: DomainEventHandler, S> RealearnAccelerator<EH, S> {
pub fn new(main_processors: SharedMainProcessors<EH>, snitch: S) -> Self {
Self {
main_processors,
snitch,
}
}
}
impl<EH, S> RealearnAccelerator<EH, S>
where
EH: DomainEventHandler,
S: HelgoboxWindowSnitch,
{
/// Sends the message to all main processors as control events.
///
/// Returns `true` if at least one main processor used the message, that is if at least one
/// main processor had control input set to "Keyboard" and a mapping matched the given key.
///
/// If yes, we should completely "eat" the message and don't do anything else with it.
fn process_control(&mut self, msg: KeyMessage) -> bool {
let evt = ControlEvent::new(msg, ControlEventTimestamp::from_main_thread());
let mut filter_out_event = false;
let mut notified_backbone = false;
for proc in &mut *self.main_processors.borrow_mut() {
if !proc.wants_keyboard_input() {
continue;
}
let result = proc.process_incoming_key_msg(evt);
// Notify backbone that ReaLearn mappings successfully matched keyboard input in this
// main loop cycle. We need to do that only once.
if !notified_backbone && result.match_outcome.matched() {
notified_backbone = true;
Backbone::get().set_keyboard_input_match_flag();
}
// If at least one instance wants to filter the key out, we filter it out, not
// passing it forward to the rest of the keyboard processing chain!
if result.filter_out_event {
filter_out_event = true;
}
}
filter_out_event
}
/// Decides what to do with the key if no main processor used it.
fn process_unmatched(&self, msg: AccelMsg) -> TranslateAccelResult {
let is_virt_key = msg.message() != AccelMsgKind::Char
&& msg.behavior().contains(AcceleratorBehavior::VirtKey);
if is_virt_key && msg.key().get() as u32 == raw::VK_ESCAPE {
// Don't process escape in special ways. We want the normal close behavior. Especially
// important for the floating ReaLearn FX window where closing the main panel would not
// close the surrounding floating window.
return TranslateAccelResult::NotOurWindow;
}
let Some(focused_window) = Window::focused() else {
// No window focused
return TranslateAccelResult::NotOurWindow;
};
let Some(view) = self.snitch.find_closest_realearn_view(focused_window) else {
// Not our window which is focused. Act normally.
return TranslateAccelResult::NotOurWindow;
};
// Support F1 in our windows (key_down and key_up don't work very well on Linux and Windows if there's
// a text field)
if is_virt_key && msg.key().get() == virt_keys::F1.get() {
if msg.message() == AccelMsgKind::KeyUp {
view.help_requested();
}
return TranslateAccelResult::Eat;
}
if !view.wants_raw_keyboard_input() {
return TranslateAccelResult::NotOurWindow;
}
let Some(w) = view.get_keyboard_event_receiver(focused_window) else {
// No window is interested.
return TranslateAccelResult::Eat;
};
// A ReaLearn window is focused.
// - We want to get almost all keyboard input (without having to enable "Send all keyboard input to plug-in")
// - We don't want REAPER to execute actions or the system to execute menu commands
// - We want the egui window to receive raw keys
//
// All of this is achieved in different ways depending on the OS.
#[cfg(target_os = "macos")]
{
// On macOS, we must explicitly send the message to the focused view. If we
// let the system take care of it (e.g. via NotOurWindow, PassOnToWindow or
// ProcessEventRaw), REAPER's main menu shortcuts have priority, which we don't
// want! Especially important for egui text edit because Cmd+C and Cmd+X would not
// work in there.
if w.process_current_app_event_if_no_text_field() {
TranslateAccelResult::Eat
} else {
// However, if the focused view is a text field, this would break copy & paste
// in that text field ;) So in this special case, we need the system to do its
// job.
TranslateAccelResult::NotOurWindow
}
}
#[cfg(not(target_os = "macos"))]
{
// This is necessary for Pot Browser to receive keys
w.process_raw_message(msg.raw());
TranslateAccelResult::Eat
}
}
}
impl<EH, S> TranslateAccel for RealearnAccelerator<EH, S>
where
EH: DomainEventHandler,
S: HelgoboxWindowSnitch,
{
fn call(&mut self, args: TranslateAccelArgs) -> TranslateAccelResult {
// Ignore char messages
if args.msg.message() == AccelMsgKind::Char {
// Char messages are only sent on Windows and for all relevant control purposes,
// they are preceded by a KeyDown event, so we must ignore them.
return self.process_unmatched(args.msg);
}
// If REAPER 7.23+, check if window is text field
let reaper = Reaper::get().medium_reaper();
if reaper.low().pointers().IsWindowTextField.is_some() {
if let Some(window) = Window::focused() {
let is_text_field = unsafe { reaper.is_window_text_field(window.raw_hwnd()) };
if is_text_field {
return self.process_unmatched(args.msg);
}
}
}
// If we end up here, it could be interesting for the main processors
let stroke = Keystroke::new(args.msg.behavior(), args.msg.key());
let normalized_stroke = stroke.normalized();
let normalized_msg = KeyMessage::new(args.msg.message(), normalized_stroke);
let filter_out = self.process_control(normalized_msg);
if filter_out {
TranslateAccelResult::Eat
} else {
self.process_unmatched(args.msg)
}
}
}
@@ -0,0 +1,711 @@
use crate::domain::{
classify_midi_message, AudioBlockProps, ControlEvent, ControlEventTimestamp,
DisplayAsPrettyHex, IncomingMidiMessage, InstanceId, MidiControlInput, MidiEvent,
MidiMessageClassification, MidiScanResult, MidiScanner, MidiTransformationContainer,
RealTimeProcessor, SharedRealTimeInstance, UnitId, GLOBAL_AUDIO_STATE,
};
use base::byte_pattern::{BytePattern, PatternByte};
use base::metrics_util::{measure_time, record_duration};
use base::non_blocking_lock;
use helgoboss_learn::{MidiSourceValue, RawMidiEvent, RawMidiEvents};
use helgoboss_midi::{DataEntryByteOrder, RawShortMessage, ShortMessage, ShortMessageType};
use helgobox_allocator::*;
use reaper_common_types::DurationInSeconds;
use reaper_high::{MidiInputDevice, MidiOutputDevice, Reaper};
use reaper_medium::{
MidiInputDeviceId, MidiOutputDeviceId, OnAudioBuffer, OnAudioBufferArgs, SendMidiTime,
MIDI_INPUT_FRAME_RATE,
};
use smallvec::SmallVec;
use std::fmt::{Display, Formatter};
use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
use std::time::{Duration, Instant};
use tinyvec::ArrayVec;
const AUDIO_HOOK_TASK_BULK_SIZE: usize = 1;
const FEEDBACK_TASK_BULK_SIZE: usize = 1000;
/// This needs to be thread-safe because if "Allow live FX multiprocessing" is active in the REAPER
/// preferences, the VST processing is executed in another thread than the audio hook!
pub type SharedRealTimeProcessor = Arc<Mutex<RealTimeProcessor>>;
pub type MidiCaptureSender = async_channel::Sender<MidiScanResult>;
#[derive(Debug)]
pub struct RequestMidiDeviceIdentityCommand {
pub output_device_id: MidiOutputDeviceId,
pub input_device_id: Option<MidiInputDeviceId>,
pub sender: async_channel::Sender<RequestMidiDeviceIdentityReply>,
}
#[derive(Debug)]
struct MidiDeviceInquiryTask {
command: RequestMidiDeviceIdentityCommand,
inquiry_sent_at: Instant,
}
#[derive(Clone, Debug)]
pub struct RequestMidiDeviceIdentityReply {
pub input_device_id: MidiInputDeviceId,
pub device_inquiry_reply: MidiDeviceInquiryReply,
}
#[derive(Clone, Debug)]
pub struct MidiDeviceInquiryReply {
pub message: ArrayVec<[u8; RawMidiEvent::MAX_LENGTH]>,
}
impl Display for MidiDeviceInquiryReply {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
DisplayAsPrettyHex(self.message.as_slice()).fmt(f)
}
}
// This kind of tasks is always processed, even after a rebirth when multiple processor syncs etc.
// have already accumulated. Because at the moment there's no way to request a full resync of all
// real-time processors from the control surface. In practice there's no danger that too many of
// those infrequent tasks accumulate so it's not an issue. Therefore the convention for now is to
// also send them when audio is not running.
#[derive(Debug)]
#[allow(clippy::large_enum_variant)]
pub enum NormalAudioHookTask {
AddRealTimeInstance(InstanceId, SharedRealTimeInstance),
RemoveRealTimeInstance(InstanceId),
/// First parameter is the ID.
//
// Having the ID saves us from unnecessarily blocking the audio thread by looking into the
// processor.
AddRealTimeProcessor(UnitId, SharedRealTimeProcessor),
RemoveRealTimeProcessor(UnitId),
StartCapturingMidi(MidiCaptureSender),
StopCapturingMidi,
/// Instructs the audio hook to send a MIDI device inquiry to the given output device.
///
/// Gives up after about one second if no response received (by dropping the sender).
///
/// Gives up immediately if the output device or optional input device is not open.
RequestMidiDeviceIdentity(RequestMidiDeviceIdentityCommand),
#[cfg(feature = "playtime")]
PlaytimeClipEngineCommand(playtime_clip_engine::rt::audio_hook::PlaytimeAudioHookCommand),
}
/// A global feedback task (which is potentially sent very frequently).
#[derive(Debug)]
pub enum FeedbackAudioHookTask {
MidiDeviceFeedback(
MidiOutputDeviceId,
MidiSourceValue<'static, RawShortMessage>,
),
SendMidi(MidiOutputDeviceId, RawMidiEvents),
}
pub fn send_midi_device_feedback(
dev_id: MidiOutputDeviceId,
value: MidiSourceValue<RawShortMessage>,
) {
if let Some(events) = value.to_raw() {
MidiOutputDevice::new(dev_id).with_midi_output(|mo| {
if let Some(mo) = mo {
for event in events {
mo.send_msg(event, SendMidiTime::Instantly);
}
}
});
} else {
let shorts = value.to_short_messages(DataEntryByteOrder::MsbFirst);
if shorts[0].is_none() {
return;
}
MidiOutputDevice::new(dev_id).with_midi_output(|mo| {
if let Some(mo) = mo {
for short in shorts.iter().flatten() {
mo.send(*short, SendMidiTime::Instantly);
}
}
});
}
}
#[derive(Debug)]
pub struct RealearnAudioHook {
state: AudioHookState,
midi_device_inquiry_task: Option<MidiDeviceInquiryTask>,
real_time_instances: SmallVec<[(InstanceId, SharedRealTimeInstance); 256]>,
real_time_processors: SmallVec<[(UnitId, SharedRealTimeProcessor); 256]>,
normal_task_receiver: crossbeam_channel::Receiver<NormalAudioHookTask>,
feedback_task_receiver: crossbeam_channel::Receiver<FeedbackAudioHookTask>,
time_of_last_run: Option<Instant>,
initialized: bool,
midi_transformation_container: MidiTransformationContainer,
#[cfg(feature = "playtime")]
clip_engine_audio_hook: playtime_clip_engine::rt::audio_hook::PlaytimeAudioHook,
}
#[derive(Debug)]
#[allow(clippy::large_enum_variant)]
pub enum AudioHookState {
Normal,
// This is not the instance-specific learning but the global one.
LearningSource {
sender: MidiCaptureSender,
midi_scanner: MidiScanner,
},
}
impl RealearnAudioHook {
pub fn new(
normal_task_receiver: crossbeam_channel::Receiver<NormalAudioHookTask>,
feedback_task_receiver: crossbeam_channel::Receiver<FeedbackAudioHookTask>,
) -> RealearnAudioHook {
Self {
state: AudioHookState::Normal,
midi_device_inquiry_task: None,
real_time_instances: Default::default(),
real_time_processors: Default::default(),
normal_task_receiver,
feedback_task_receiver,
time_of_last_run: None,
initialized: false,
midi_transformation_container: MidiTransformationContainer::new(),
#[cfg(feature = "playtime")]
clip_engine_audio_hook: playtime_clip_engine::rt::audio_hook::PlaytimeAudioHook::new(),
}
}
/// This should be called only once in the audio hardware thread, before everything else.
///
/// It does some per-thread allocation.
fn init_from_rt_thread(&mut self) {
// We have code, e.g. triggered by crossbeam_channel that requests the ID of the
// current thread. This operation needs an allocation at the first time it's executed
// on a specific thread. Let's do it here, globally exactly once. Then we can
// use assert_no_alloc() to detect real regular allocation issues.
// Please note that this doesn't have an effect if
// "Audio => Buffering => Allow live FX multiprocessing" is enabled in the REAPER prefs.
// Because then worker threads will drive ReaLearn plug-in and clips. That's not an
// issue for actual usage because the allocation is done only once per worker
// thread, right at the beginning. It's only a problem for testing with
// assert_no_alloc(). We introduced a similar thing in ColumnSource get_samples.
let thread_id = std::thread::current().id();
// The tracing library also does some allocation per thread (independent from the
// allocations that a subscriber does anyway).
tracing::info!(
"Initializing real-time logging from preview register (thread {:?})",
thread_id
);
#[cfg(feature = "playtime")]
{
self.clip_engine_audio_hook.init_from_rt_thread();
}
}
fn on_pre(&mut self, args: OnAudioBufferArgs) {
let current_time = Instant::now();
let time_of_last_run = self.time_of_last_run.replace(current_time);
// Increment counter
let block_props = AudioBlockProps::from_on_audio_buffer_args(&args);
let block_count = GLOBAL_AUDIO_STATE.advance(block_props);
let sample_count = block_count * args.len as u64;
// Call ReaLearn real-time processors (= process MIDI messages coming in from hardware devices).
// We do this here already, *before* pre-polling recording and advancing Playtime's tempo buffer (done in `on_pre_poll_1`)!
// Reason: When recording a new clip with tempo detection (= recording in silence mode), it's ideal
// if pressing a stop button on the controller *instantly* stops recording, detects the new tempo,
// applies it to the current block and starts playing the clip. Instantly = at the start of *this* block,
// without waiting until the next block. This is only possible if at the very beginning of the
// block it's already known that the stop button was pressed, before the block tempo props are determined.
//
// Playtime steps:
//
// 1. Process MIDI messages coming in from hardware devices
// 2. For each record-clip task:
// 2.1 Write incoming data to recording clip
// 2.2 Commit recording if necessary (if tempo detection enabled, also reset timeline with new tempo and no count-in)
// - Maybe we can unify manual stop and scheduled stop this way
// - One way is to process RtColumn commands generally in the audio hook.
// - Pro: No need to make "stop recording" a special command. All commands will be executed before preview registers,
// that is, before other columns have been played.
// - Con: I had the idea of a future refactoring: To do resolving/scheduling eagerly when processing
// the command instead of later when processing the slots. Since block tempo props are by definition
// not 100% decided yet when processing commands from the audio hook in this early stage, this would
// become impossible.
//
// 3. Advance tempo buffer
// 4. Play clip from start (process preview registers, which REAPER does after executing the pre-audio-hook)
let might_be_rebirth = {
if let Some(time) = time_of_last_run {
current_time.duration_since(time) > Duration::from_secs(1)
} else {
false
}
};
self.call_real_time_processors(block_props, sample_count, might_be_rebirth);
// Process ReaLearn feedback commands
self.process_feedback_commands();
// Process incoming commands, including Playtime commands
self.process_normal_commands(block_props);
// Pre-poll Playtime
#[cfg(feature = "playtime")]
{
self.clip_engine_audio_hook
.on_pre_poll(block_props.to_playtime(), args.reg);
}
// Poll real-time instances. If an instance has Playtime enabled, this also polls the real-time matrix.
// Important to do after pre-polling the Playtime audio hook, especially for one scenario:
// Leaving silence mode immediately with playing ignited clips: In this case, we do a timeline reset to zero.
// The ignited clips should start immediately, exactly from zero as soon as the timeline has been reset.
// If we called this before pre-polling Playtime audio hook, the real-time matrix would be called in the
// next audio cycle, after the timeline has already advanced on block from zero.
self.pre_poll_real_time_instances(block_props);
// Process some tasks
self.check_for_midi_device_inquiry_response();
}
fn on_post(&mut self, args: OnAudioBufferArgs) {
self.post_poll_real_time_instances();
#[cfg(not(feature = "playtime"))]
{
let _ = args;
}
#[cfg(feature = "playtime")]
{
let block_props = AudioBlockProps::from_on_audio_buffer_args(&args);
self.clip_engine_audio_hook
.on_post(block_props.to_playtime(), args.reg);
}
// Record some metrics
if let Some(time_of_last_run) = self.time_of_last_run {
record_duration("helgobox.rt.audio_hook.total", time_of_last_run.elapsed());
}
}
fn process_feedback_commands(&mut self) {
// Process global direct device feedback (since v2.8.0-pre6) - in order to
// have deterministic feedback ordering, which is important for multi-instance
// orchestration.
for task in self
.feedback_task_receiver
.try_iter()
.take(FEEDBACK_TASK_BULK_SIZE)
{
use FeedbackAudioHookTask::*;
match task {
MidiDeviceFeedback(dev_id, value) => {
send_midi_device_feedback(dev_id, value);
}
SendMidi(dev_id, raw_midi_events) => {
MidiOutputDevice::new(dev_id).with_midi_output(|mo| {
if let Some(mo) = mo {
for event in &raw_midi_events {
mo.send_msg(event, SendMidiTime::Instantly);
}
}
});
}
}
}
}
fn pre_poll_real_time_instances(&self, block_props: AudioBlockProps) {
for (_, i) in self.real_time_instances.iter() {
non_blocking_lock(i, "RealTimeInstance pre_poll").pre_poll(block_props);
}
}
fn post_poll_real_time_instances(&self) {
for (_, i) in self.real_time_instances.iter() {
non_blocking_lock(i, "RealTimeInstance post_poll").post_poll();
}
}
fn call_real_time_processors(
&mut self,
block_props: AudioBlockProps,
sample_count: u64,
might_be_rebirth: bool,
) {
match &mut self.state {
AudioHookState::Normal => {
self.call_real_time_processors_in_normal_state(
block_props,
might_be_rebirth,
sample_count,
);
}
AudioHookState::LearningSource {
sender,
midi_scanner,
} => {
for (_, p) in self.real_time_processors.iter() {
p.lock_recover()
.run_from_audio_hook_essential(might_be_rebirth);
}
for dev in Reaper::get().midi_input_devices() {
dev.with_midi_input(|mi| {
if let Some(mi) = mi {
for e in mi.get_read_buf() {
if let Some(res) = scan_midi(dev.id(), e, midi_scanner) {
let _ = sender.try_send(res);
}
}
}
});
}
if let Some(res) = midi_scanner.poll() {
// Source detected via polling. Return to normal mode.
let _ = sender.try_send(res);
}
}
};
}
fn call_real_time_processors_in_normal_state(
&mut self,
block_props: AudioBlockProps,
might_be_rebirth: bool,
sample_count: u64,
) {
// 1a. Drive real-time processors and determine used MIDI devices "on the go".
//
// Calling the real-time processor *before* processing its remove task has
// the benefit that it can still do some final work (e.g. clearing
// LEDs by sending zero feedback) before it's removed. That's also
// one of the reasons why we remove the real-time processor async by
// sending a message. It's okay if it's around for one cycle after a
// plug-in instance has unloaded (only the case if not the last instance).
//
let mut midi_dev_id_is_used = [false; MidiInputDeviceId::MAX_DEVICE_COUNT as usize];
let mut midi_devs_used_at_all = false;
let start_of_block_timestamp = ControlEventTimestamp::from_rt(
sample_count,
block_props.frame_rate,
DurationInSeconds::ZERO,
);
for (_, p) in self.real_time_processors.iter() {
// Since 1.12.0, we "drive" each plug-in instance's real-time processor
// primarily by the global audio hook. See https://github.com/helgoboss/helgobox/issues/84 why this is
// better. We also call it by the plug-in `process()` method though in order
// to be able to send MIDI to <FX output> and to
// stop doing so synchronously if the plug-in is
// gone.
let mut guard = p.lock_recover();
guard.run_from_audio_hook_all(might_be_rebirth, start_of_block_timestamp);
if guard.control_is_globally_enabled() {
if let MidiControlInput::Device(dev_id) = guard.midi_control_input() {
midi_dev_id_is_used[dev_id.get() as usize] = true;
midi_devs_used_at_all = true;
}
}
}
// 1b. Forward MIDI events from MIDI devices to ReaLearn instances and filter
// them globally if desired by the instance.
if midi_devs_used_at_all {
self.distribute_midi_events_to_processors(
block_props,
&midi_dev_id_is_used,
sample_count,
);
}
}
fn distribute_midi_events_to_processors(
&mut self,
block_props: AudioBlockProps,
midi_dev_id_is_used: &[bool; MidiInputDeviceId::MAX_DEVICE_COUNT as usize],
sample_count: u64,
) {
self.midi_transformation_container
.prepare(block_props.frame_rate);
for dev_id in 0..MidiInputDeviceId::MAX_DEVICE_COUNT {
if !midi_dev_id_is_used[dev_id as usize] {
continue;
}
let dev_id = MidiInputDeviceId::new(dev_id);
MidiInputDevice::new(dev_id).with_midi_input(|mi| {
if let Some(mi) = mi {
let event_list = mi.get_read_buf();
let mut bpos = 0;
while let Some(res) = event_list.enum_items(bpos) {
let next_bpos = res.next_bpos;
// Current control mode is checked further down the callstack. No need to
// check it here.
let our_event =
match MidiEvent::from_reaper(res.midi_event, block_props.frame_rate) {
Err(_) => continue,
Ok(e) => e,
};
let frame_offset_in_secs =
res.midi_event.frame_offset() as f64 / MIDI_INPUT_FRAME_RATE.get();
let timestamp = ControlEventTimestamp::from_rt(
sample_count,
block_props.frame_rate,
DurationInSeconds::new_panic(frame_offset_in_secs),
);
let our_event = ControlEvent::new(our_event, timestamp);
let mut filter_out_event = false;
for (_, p) in self.real_time_processors.iter() {
let mut guard = p.lock_recover();
if guard.control_is_globally_enabled()
&& guard.midi_control_input() == MidiControlInput::Device(dev_id)
&& guard.process_incoming_midi_from_audio_hook(
our_event,
&mut self.midi_transformation_container,
)
{
filter_out_event = true;
}
}
if filter_out_event {
// Take event out of input buffer. In this case, we must not adjust bpos
// because just deleting the item has the same effect.
event_list.delete_item(bpos);
} else {
// Move cursor to next position
bpos = next_bpos;
}
}
// Add transformed events *after* iterating
for event in self
.midi_transformation_container
.drain_same_device_events()
{
let reaper_event = reaper_medium::MidiEvent::from_raw_ref(event.as_ref());
event_list.add_item(reaper_event);
}
}
});
}
// Process MIDI "MIDI: Send message" to "Device input" across multiple devices
for evt in self
.midi_transformation_container
.drain_other_device_events()
{
MidiInputDevice::new(evt.input_device_id).with_midi_input(|mi| {
if let Some(mi) = mi {
let event_list = mi.get_read_buf();
let reaper_event = reaper_medium::MidiEvent::from_raw_ref(evt.event.as_ref());
event_list.add_item(reaper_event);
}
});
}
}
fn process_midi_device_inquiry_command(
&mut self,
command: RequestMidiDeviceIdentityCommand,
) -> Result<(), &'static str> {
let output_dev_id = command.output_device_id;
let output_dev = Reaper::get().midi_output_device_by_id(output_dev_id);
output_dev.with_midi_output(|output| -> Result<(), &'static str> {
let output = output.ok_or("MIDI output device not open")?;
let inquiry = RawMidiEvent::try_from_slice(0, MIDI_DEVICE_INQUIRY_REQUEST)?;
tracing::debug!(msg = "Sending MIDI device inquiry...", ?output_dev_id);
output.send_msg(inquiry, SendMidiTime::Instantly);
Ok(())
})?;
let task = MidiDeviceInquiryTask {
command,
inquiry_sent_at: Instant::now(),
};
self.midi_device_inquiry_task = Some(task);
Ok(())
}
fn check_for_midi_device_inquiry_response(&mut self) {
let Some(task) = self.midi_device_inquiry_task.as_ref() else {
// No task
return;
};
if !task.check_for_midi_device_inquiry_response() {
// Task done
self.midi_device_inquiry_task = None;
}
}
fn process_normal_commands(&mut self, block_props: AudioBlockProps) {
use NormalAudioHookTask::*;
let mut count = 0;
while let Ok(task) = self.normal_task_receiver.try_recv() {
match task {
AddRealTimeInstance(id, p) => {
self.real_time_instances.push((id, p));
}
RemoveRealTimeInstance(id) => {
if let Some(pos) = self.real_time_instances.iter().position(|(i, _)| i == &id) {
self.real_time_instances.swap_remove(pos);
}
}
AddRealTimeProcessor(id, p) => {
self.real_time_processors.push((id, p));
}
RemoveRealTimeProcessor(id) => {
if let Some(pos) = self.real_time_processors.iter().position(|(i, _)| i == &id)
{
self.real_time_processors.swap_remove(pos);
}
}
StartCapturingMidi(sender) => {
self.state = AudioHookState::LearningSource {
sender,
midi_scanner: Default::default(),
}
}
StopCapturingMidi => {
self.state = AudioHookState::Normal;
}
RequestMidiDeviceIdentity(command) => {
let _ = self.process_midi_device_inquiry_command(command);
}
#[cfg(feature = "playtime")]
PlaytimeClipEngineCommand(command) => {
let _ = self
.clip_engine_audio_hook
.on_pre_process_command(command, block_props.to_playtime());
}
}
// Don't take too much at once
count += 1;
if count == AUDIO_HOOK_TASK_BULK_SIZE {
break;
}
}
let _ = block_props;
}
}
impl OnAudioBuffer for RealearnAudioHook {
fn call(&mut self, args: OnAudioBufferArgs) {
if !self.initialized {
self.init_from_rt_thread();
self.initialized = true;
}
assert_no_alloc(|| {
let is_pre = !args.is_post;
if is_pre {
measure_time("helgobox.rt.audio_hook.pre", || {
self.on_pre(args);
});
} else {
measure_time("helgobox.rt.audio_hook.post", || {
self.on_post(args);
});
}
});
}
}
fn scan_midi(
dev_id: MidiInputDeviceId,
evt: &reaper_medium::MidiEvent,
midi_scanner: &mut MidiScanner,
) -> Option<MidiScanResult> {
let msg = IncomingMidiMessage::from_reaper(evt.message()).ok()?;
if classify_midi_message(msg) != MidiMessageClassification::Normal {
return None;
}
use IncomingMidiMessage::*;
match msg {
Short(short_msg) => midi_scanner.feed_short(short_msg, Some(dev_id)),
SysEx(bytes) => {
// It's okay here to temporarily permit allocation because crackling during learning
// is not a showstopper.
permit_alloc(|| MidiScanResult::try_from_bytes(bytes, Some(dev_id)).ok())
}
}
}
pub trait RealTimeProcessorLocker {
fn lock_recover(&self) -> MutexGuard<RealTimeProcessor>;
}
impl RealTimeProcessorLocker for SharedRealTimeProcessor {
/// This ignores poisoning, which is okay in our case because if the real-time
/// processor has panicked, we will see it in the REAPER console. No need to
/// hide that error with lots of follow-up poisoning errors! This is a kind of
/// recovery mechanism.
fn lock_recover(&self) -> MutexGuard<RealTimeProcessor> {
non_blocking_lock(self, "RealTimeProcessor")
}
}
impl MidiDeviceInquiryTask {
/// Returns `false` if task not necessary anymore.
pub fn check_for_midi_device_inquiry_response(&self) -> bool {
// Give up if waited too long for response.
if self.inquiry_sent_at.elapsed() > Duration::from_secs(1) {
tracing::debug!(msg = "Gave up waiting for MIDI device identity reply after timeout");
return false;
}
// Check MIDI devices in question for response
if let Some(id) = self.command.input_device_id {
// Check user-defined input device for possible response
let dev = Reaper::get().midi_input_device_by_id(id);
if !self.process_input_dev(dev) {
return false;
}
} else {
// Check all input devices for possible response
for dev in Reaper::get().midi_input_devices() {
if !self.process_input_dev(dev) {
return false;
}
}
}
// Return true as long as we haven't got a response yet.
true
}
/// Returns `false` if task not necessary anymore.
fn process_input_dev(&self, dev: MidiInputDevice) -> bool {
dev.with_midi_input(|mi| {
let Some(mi) = mi else {
return true;
};
for evt in mi.get_read_buf() {
let msg = evt.message();
let Ok(short) = msg.to_short_message() else {
continue;
};
if short.r#type() == ShortMessageType::SystemExclusiveStart {
let reply_pattern = &MIDI_DEVICE_INQUIRY_REPLY_PATTERN;
let reply_pattern =
reply_pattern.get_or_init(create_device_inquiry_reply_pattern);
let is_identity_reply = reply_pattern.matches(msg.as_slice());
let Ok(message) = ArrayVec::try_from(msg.as_slice()) else {
// Couldn't store the reply in the array. Shouldn't happen here because
// we set the ArrayVec's capacity to the max size of the raw event.
// So at a maximum it will be cropped.
return false;
};
if is_identity_reply {
let reply = RequestMidiDeviceIdentityReply {
input_device_id: dev.id(),
device_inquiry_reply: MidiDeviceInquiryReply { message },
};
tracing::debug!(msg = "Received MIDI device identity reply", ?reply);
let _ = self.command.sender.try_send(reply);
return false;
}
}
}
true
})
}
}
const MIDI_DEVICE_INQUIRY_REQUEST: &[u8] = &[0xF0, 0x7E, 0x7F, 0x06, 0x01, 0xF7];
static MIDI_DEVICE_INQUIRY_REPLY_PATTERN: OnceLock<BytePattern> = OnceLock::new();
fn create_device_inquiry_reply_pattern() -> BytePattern {
use Fixed as F;
use PatternByte::*;
BytePattern::new(vec![
F(0xF0),
F(0x7E),
Single,
F(0x06),
F(0x02),
Multi,
F(0xF7),
])
}
@@ -0,0 +1,865 @@
use base::{
make_available_globally_in_main_thread_on_demand, NamedChannelSender, SenderToNormalThread,
};
use crate::domain::{
AdditionalFeedbackEvent, ControlInput, DeviceControlInput, DeviceFeedbackOutput,
FeedbackOutput, InstanceId, QualifiedStreamDeckMessage, RealearnSourceState,
RealearnTargetState, ReaperTarget, ReaperTargetType, SafeLua, SharedInstance,
StreamDeckDeviceId, StreamDeckDeviceManager, StreamDeckMessage,
StreamDeckSourceFeedbackPayload, StreamDeckSourceFeedbackValue, UnitId, WeakInstance,
};
#[allow(unused)]
use anyhow::{anyhow, Context};
use pot::{PotFavorites, PotFilterExcludes};
use ab_glyph::{FontRef, PxScale};
use base::hash_util::{NonCryptoHashMap, NonCryptoHashSet};
use cached::proc_macro::cached;
use camino::Utf8PathBuf;
use fragile::Fragile;
use helgoboss_learn::{RgbColor, UnitValue};
use helgobox_api::persistence::{
StreamDeckButtonBackground, StreamDeckButtonForeground, TargetTouchCause,
};
use imageproc::definitions::{HasBlack, HasWhite};
use once_cell::sync::Lazy;
// Use once_cell::sync::Lazy instead of std::sync::LazyLock to be able to build with Rust 1.77.2 (to stay Win7-compatible)
use once_cell::sync::Lazy as LazyLock;
use palette::IntoColor;
use reaper_high::{Fx, Reaper};
use std::cell::{Cell, Ref, RefCell, RefMut};
use std::cmp::min;
use std::hash::Hash;
use std::rc::Rc;
use std::sync::RwLock;
use std::time::{Duration, Instant};
use streamdeck::StreamDeck;
use strum::EnumCount;
make_available_globally_in_main_thread_on_demand!(Backbone);
/// Just the old term as alias for easier class search.
type _BackboneState = Backbone;
/// This is the domain-layer "backbone" which can hold state that's shared among all ReaLearn
/// instances.
pub struct Backbone {
time_of_start: Instant,
additional_feedback_event_sender: SenderToNormalThread<AdditionalFeedbackEvent>,
source_state: RefCell<RealearnSourceState>,
target_state: RefCell<RealearnTargetState>,
last_touched_targets_container: RefCell<LastTouchedTargetsContainer>,
/// Value: Instance ID of the ReaLearn instance that owns the control input.
control_input_usages: RefCell<NonCryptoHashMap<DeviceControlInput, NonCryptoHashSet<UnitId>>>,
/// Value: Instance ID of the ReaLearn instance that owns the feedback output.
feedback_output_usages:
RefCell<NonCryptoHashMap<DeviceFeedbackOutput, NonCryptoHashSet<UnitId>>>,
superior_units: RefCell<NonCryptoHashSet<UnitId>>,
/// We hold pointers to all ReaLearn instances in order to let instance B
/// borrow a Playtime matrix which is owned by instance A. This is great because it allows us to
/// control the same Playtime matrix from different controllers.
// TODO-high-playtime-refactoring Since the introduction of units, foreign matrices are not used in practice. Let's
// keep this for a while and remove.
instances: RefCell<NonCryptoHashMap<InstanceId, WeakInstance>>,
was_processing_keyboard_input: Cell<bool>,
global_pot_filter_exclude_list: RefCell<PotFilterExcludes>,
recently_focused_fx_container: Rc<RefCell<RecentlyFocusedFxContainer>>,
stream_deck_device_manager: RefCell<StreamDeckDeviceManager>,
stream_decks: RefCell<NonCryptoHashMap<StreamDeckDeviceId, StreamDeck>>,
stream_deck_button_states: RefCell<NonCryptoHashMap<StreamDeckDeviceId, Vec<u8>>>,
}
#[derive(Debug, Default)]
pub struct AnyThreadBackboneState {
/// Thread-safe because we need to access the favorites both from the main thread (e.g. for
/// display purposes) and from the pot worker (for building the collections). Alternative would
/// be to clone the favorites whenever we build the collections.
pub pot_favorites: RwLock<PotFavorites>,
}
impl AnyThreadBackboneState {
pub fn get() -> &'static AnyThreadBackboneState {
static INSTANCE: Lazy<AnyThreadBackboneState> = Lazy::new(AnyThreadBackboneState::default);
&INSTANCE
}
}
struct LastTouchedTargetsContainer {
/// Contains the most recently touched targets at the end!
last_target_touches: Vec<TargetTouch>,
}
struct TargetTouch {
pub target: ReaperTarget,
pub caused_by_realearn: bool,
}
impl Default for LastTouchedTargetsContainer {
fn default() -> Self {
// Each target type can be there twice: Once touched via ReaLearn, once touched in other way
let max_count = ReaperTargetType::COUNT * 2;
Self {
last_target_touches: Vec::with_capacity(max_count),
}
}
}
impl LastTouchedTargetsContainer {
/// Returns `true` if the last touched target has changed.
pub fn update(&mut self, event: TargetTouchEvent) -> bool {
// Don't do anything if the given target is the same as the last touched one
if let Some(last_target_touch) = self.last_target_touches.last() {
if event.target == last_target_touch.target
&& event.caused_by_realearn == last_target_touch.caused_by_realearn
{
return false;
}
}
// Remove all previous entries of that target type and conditions
let last_touched_target_type = ReaperTargetType::from_target(&event.target);
self.last_target_touches.retain(|t| {
ReaperTargetType::from_target(&t.target) != last_touched_target_type
|| t.caused_by_realearn != event.caused_by_realearn
});
// Push it as last touched target
let touch = TargetTouch {
target: event.target,
caused_by_realearn: event.caused_by_realearn,
};
self.last_target_touches.push(touch);
true
}
pub fn find(&self, filter: LastTouchedTargetFilter) -> Option<&ReaperTarget> {
let touch = self.last_target_touches.iter().rev().find(|t| {
match filter.touch_cause {
TargetTouchCause::Reaper if t.caused_by_realearn => return false,
TargetTouchCause::Realearn if !t.caused_by_realearn => return false,
_ => {}
}
let target_type = ReaperTargetType::from_target(&t.target);
filter.included_target_types.contains(&target_type)
})?;
Some(&touch.target)
}
}
pub struct LastTouchedTargetFilter<'a> {
pub included_target_types: &'a NonCryptoHashSet<ReaperTargetType>,
pub touch_cause: TargetTouchCause,
}
impl LastTouchedTargetFilter<'_> {
pub fn matches(&self, event: &TargetTouchEvent) -> bool {
// Check touch cause
match self.touch_cause {
TargetTouchCause::Realearn if !event.caused_by_realearn => return false,
TargetTouchCause::Reaper if event.caused_by_realearn => return false,
_ => {}
}
// Check target types
let actual_target_type = ReaperTargetType::from_target(&event.target);
self.included_target_types.contains(&actual_target_type)
}
}
impl Backbone {
pub fn new(
additional_feedback_event_sender: SenderToNormalThread<AdditionalFeedbackEvent>,
target_context: RealearnTargetState,
) -> Self {
Self {
time_of_start: Instant::now(),
additional_feedback_event_sender,
source_state: Default::default(),
target_state: RefCell::new(target_context),
last_touched_targets_container: Default::default(),
control_input_usages: Default::default(),
feedback_output_usages: Default::default(),
superior_units: Default::default(),
instances: Default::default(),
was_processing_keyboard_input: Default::default(),
global_pot_filter_exclude_list: Default::default(),
recently_focused_fx_container: Default::default(),
stream_deck_device_manager: Default::default(),
stream_decks: Default::default(),
stream_deck_button_states: Default::default(),
}
}
/// Returns IDs of newly connected Stream Deck devices.
pub fn detect_stream_deck_device_changes(&self) -> NonCryptoHashSet<StreamDeckDeviceId> {
let devices_in_use = self.stream_deck_device_manager.borrow().devices_in_use();
let actually_connected_devices: NonCryptoHashSet<_> =
self.stream_decks.borrow().keys().copied().collect();
if devices_in_use == actually_connected_devices {
return Default::default();
}
self.connect_or_disconnect_stream_deck_devices(&devices_in_use)
}
pub fn register_stream_deck_usage(&self, unit_id: UnitId, device: Option<StreamDeckDeviceId>) {
// Change device usage
let mut manager = self.stream_deck_device_manager.borrow_mut();
manager.register_device_usage(unit_id, device);
let devices_in_use = manager.devices_in_use();
// Update connections
self.connect_or_disconnect_stream_deck_devices(&devices_in_use);
}
fn connect_or_disconnect_stream_deck_devices(
&self,
devices_in_use: &NonCryptoHashSet<StreamDeckDeviceId>,
) -> NonCryptoHashSet<StreamDeckDeviceId> {
let mut decks = self.stream_decks.borrow_mut();
// Disconnect from devices that are not in use anymore
decks.retain(|id, _| devices_in_use.contains(id));
// Connect to devices
devices_in_use
.iter()
.filter_map(|dev_id| {
if decks.contains_key(dev_id) {
return None;
}
match dev_id.connect() {
Ok(dev) => {
decks.insert(*dev_id, dev);
Some(*dev_id)
}
Err(e) => {
tracing::warn!(msg = "Couldn't connect to Stream Deck device", %e);
None
}
}
})
.collect()
}
pub fn set_stream_deck_brightness(
&self,
dev_id: StreamDeckDeviceId,
percent: UnitValue,
) -> anyhow::Result<()> {
let mut decks = self.stream_decks.borrow_mut();
let sd = decks
.get_mut(&dev_id)
.context("stream deck not connected")?;
sd.set_brightness((percent.get() * 100.0).round() as _)?;
Ok(())
}
pub fn poll_stream_deck_messages(&self) -> Vec<QualifiedStreamDeckMessage> {
let mut decks = self.stream_decks.borrow_mut();
let mut button_states = self.stream_deck_button_states.borrow_mut();
let mut messages = vec![];
decks.retain(|id, deck| {
let result = poll_stream_deck_messages(&mut messages, *id, deck, &mut button_states);
match result {
Ok(_) => true,
Err(streamdeck::Error::NoData) => true,
Err(e) => {
tracing::warn!(msg = "Error polling for stream deck events", %e);
false
}
}
});
messages
}
pub fn send_stream_deck_feedback(
&self,
dev_id: StreamDeckDeviceId,
value: StreamDeckSourceFeedbackValue,
) -> anyhow::Result<()> {
use image::{Pixel, Rgba, RgbaImage};
const DEFAULT_BG_COLOR: RgbColor = RgbColor::BLACK;
const DEFAULT_FG_COLOR: RgbColor = RgbColor::WHITE;
#[derive(Eq, PartialEq, Clone, Hash)]
enum ResizeMode {
Square,
ShortestSide,
}
fn adjust_size(dimension: u32, factor: f64) -> u32 {
(dimension as f64 * factor).floor() as u32
}
#[cached(result = true)]
fn load_image_for_stream_deck(
path: String,
size: u32,
resize_mode: ResizeMode,
) -> anyhow::Result<RgbaImage> {
let path: Utf8PathBuf = path.into();
let path = if path.is_relative() {
Reaper::get().resource_path().join(path)
} else {
path
};
let image = image::open(path)?;
let (width, height) = match resize_mode {
ResizeMode::Square => (size, size),
ResizeMode::ShortestSide => {
let factor = size as f64 / min(image.width(), image.height()) as f64;
(
adjust_size(image.width(), factor),
adjust_size(image.height(), factor),
)
}
};
let image = image
.resize_to_fill(width, height, image::imageops::FilterType::Lanczos3)
.into();
Ok(image)
}
let mut error_msg: Option<String> = None;
let mut load_image_for_stream_deck_reporting_error =
|path: String, size: u32, resize_mode: ResizeMode| -> Option<RgbaImage> {
load_image_for_stream_deck(path, size, resize_mode)
.inspect_err(|e| {
error_msg = Some(e.to_string());
})
.ok()
};
fn set_alpha(pixel: &mut Rgba<u8>, alpha: f32) {
pixel[3] = (alpha * 255.0).round() as u8;
}
fn overlay_with_image(target: &mut RgbaImage, overlay: &RgbaImage, alpha: Option<f32>) {
for (x, y, target_pixel) in target.enumerate_pixels_mut() {
let mut overlay_pixel = *overlay.get_pixel(x, y);
if let Some(alpha) = alpha {
set_alpha(&mut overlay_pixel, alpha);
}
target_pixel.blend(&overlay_pixel)
}
}
fn overlay_with_color(target: &mut RgbaImage, mut overlay: Rgba<u8>, alpha: f32) {
set_alpha(&mut overlay, alpha);
for pixel in target.pixels_mut() {
pixel.blend(&overlay);
}
}
use std::f32::consts::PI;
/// Draws a knob with an indicator showing the given value (0% to 100%).
/// The knob is centered within the specified width and height.
/// - `width`: The width of the image.
/// - `height`: The height of the image.
/// - `value`: The value to display, from 0.0 to 100.0 (percentage).
/// - `filename`: The filename to save the image as.
fn draw_knob(img: &mut RgbaImage, width: u32, height: u32, value: f32) {
let cx = width as f32 / 2.0;
let cy = height as f32 / 2.0;
// Circle
let radius = cx.min(cy) - 10.0;
let gray = 40;
let knob_color = Rgba([gray, gray, gray, 100]);
imageproc::drawing::draw_filled_circle_mut(
img,
(cx as _, cy as _),
radius as _,
knob_color,
);
// Indicator line
let angle = value * 1.5 * PI + PI * 0.75;
let line_length = radius - 5.0;
let line_x = cx + line_length * angle.cos();
let line_y = cy + line_length * angle.sin();
let indicator_color = Rgba::white();
draw_line(
img,
cx as i32,
cy as i32,
line_x as i32,
line_y as i32,
indicator_color,
);
}
/// Draws a simple line between two points on the image using Bresenham's line algorithm.
fn draw_line(img: &mut RgbaImage, x0: i32, y0: i32, x1: i32, y1: i32, color: Rgba<u8>) {
imageproc::drawing::draw_antialiased_line_segment_mut(
img,
(x0, y0),
(x1, y1),
color,
|i, _, _| i,
);
}
let mut stream_decks = self.stream_decks.borrow_mut();
let sd = stream_decks
.get_mut(&dev_id)
.context("stream deck not connected")?;
let button_size = sd.kind().image_size().0 as u32;
let StreamDeckSourceFeedbackPayload::On(payload) = value.payload else {
// Switch display off
let black = RgbaImage::from_pixel(button_size, button_size, Rgba::black());
sd.set_button_image(value.button_index as _, black.into())?;
return Ok(());
};
// Paint grounding (important for images with alpha channel)
let bg_color: Rgba<u8> = payload.background_color.unwrap_or(DEFAULT_BG_COLOR).into();
let mut bg_layer = RgbaImage::from_pixel(button_size, button_size, bg_color);
// Paint background
let solid_bg_color = match payload.button_design.background {
StreamDeckButtonBackground::Color(_) => Some(bg_color),
StreamDeckButtonBackground::Image(b) => {
if let Some(bg_img) = load_image_for_stream_deck_reporting_error(
b.path,
button_size,
ResizeMode::Square,
) {
overlay_with_image(&mut bg_layer, &bg_img, None);
None
} else {
Some(bg_color)
}
}
};
// Paint foreground
let mut fg_color: Rgba<u8> = payload.foreground_color.unwrap_or(DEFAULT_FG_COLOR).into();
let solid_bg_color = if payload.numeric_value.is_some() {
let numeric_value = payload.numeric_value.unwrap_or(UnitValue::MIN).get() as f32;
match payload.button_design.foreground {
StreamDeckButtonForeground::None => solid_bg_color,
StreamDeckButtonForeground::FadingColor(_) => {
overlay_with_color(&mut bg_layer, fg_color, numeric_value);
// If the grounding was a solid color, the result is a solid color
solid_bg_color.map(|mut c| {
set_alpha(&mut fg_color, numeric_value);
c.blend(&fg_color);
c
})
}
StreamDeckButtonForeground::FadingImage(b) => {
let mut fg_layer = RgbaImage::from_pixel(button_size, button_size, fg_color);
if let Some(fg_img) = load_image_for_stream_deck_reporting_error(
b.path,
button_size,
ResizeMode::Square,
) {
overlay_with_image(&mut fg_layer, &fg_img, None);
}
overlay_with_image(&mut bg_layer, &fg_layer, Some(numeric_value));
// An image can have any colors, so we can't assume that the result is solid
None
}
StreamDeckButtonForeground::SlidingImage(b) => {
if let Some(fg_img) = load_image_for_stream_deck_reporting_error(
b.path,
button_size,
ResizeMode::ShortestSide,
) {
let (longest_size_of_img, height_is_longest) =
if fg_img.width() < fg_img.height() {
(fg_img.height(), true)
} else {
(fg_img.width(), false)
};
let max_pos_offset = longest_size_of_img - button_size;
let pos_offset = (numeric_value * max_pos_offset as f32).floor() as u32;
for (button_x, button_y, target_pixel) in bg_layer.enumerate_pixels_mut() {
let (fg_x, fg_y) = if height_is_longest {
(button_x, pos_offset + button_y)
} else {
(pos_offset + button_x, button_y)
};
let fg_pixel = fg_img
.get_pixel_checked(fg_x, fg_y)
.copied()
.unwrap_or(Rgba([0, 0, 0, 0]));
target_pixel.blend(&fg_pixel)
}
}
// An image can have any colors, so we can't assume that the result is solid
None
}
StreamDeckButtonForeground::FullBar(_) => {
fg_color[3] = 100;
let rect_height = (button_size as f32 * numeric_value) as u32;
// Fill the background
for (_, y, pixel) in bg_layer.enumerate_pixels_mut() {
if y >= button_size - rect_height {
pixel.blend(&fg_color);
}
}
// The bar changes the result quite a bit, so we can't assume that the result is solid
None
}
StreamDeckButtonForeground::Knob(_) => {
draw_knob(&mut bg_layer, button_size, button_size, numeric_value);
// The knob changes the result quite a bit, so we can't assume that the result is solid
None
}
}
} else {
solid_bg_color
};
// Draw text
let text = error_msg
.or(payload.text_value)
.unwrap_or(payload.button_design.static_text);
if !text.trim().is_empty() {
static FONT: LazyLock<FontRef> = LazyLock::new(|| {
FontRef::try_from_slice(include_bytes!("./Exo2-Light.otf")).unwrap()
});
let text_color = if let Some(c) = solid_bg_color {
// Background is a solid color. Calculate the contrast color.
let solid_bg_color = palette::Srgb::new(c[0], c[1], c[2]);
let solid_bg_color: palette::Srgb = solid_bg_color.into_format();
let lab: palette::Lab = solid_bg_color.into_color();
if lab.l > 50.0 {
// Bright background => Dark text color
Rgba::black()
} else {
// Dark background => Bright text color
Rgba::white()
}
} else {
// Background is not a solid color, so we need to add some background to ensure the text is visible
overlay_with_color(&mut bg_layer, Rgba::black(), 0.5);
Rgba::white()
};
let num_display_lines = 4;
let line_height = (button_size as f32 / num_display_lines as f32).round() as u32;
let scale = PxScale::from(line_height as f32);
let num_text_lines = text.lines().count() as u32;
// Center text vertically
let y_offset = button_size.saturating_sub(num_text_lines * line_height) / 2;
for (l, text_line) in text.lines().take(num_display_lines).enumerate() {
let (text_line_width, _) = imageproc::drawing::text_size(scale, &*FONT, text_line);
// Center text horizontally
let x = button_size.saturating_sub(text_line_width) / 2;
let y = y_offset + l as u32 * line_height;
imageproc::drawing::draw_text_mut(
&mut bg_layer,
text_color,
x as _,
y as _,
scale,
&*FONT,
text_line,
);
}
}
sd.set_button_image(value.button_index as _, bg_layer.into())?;
Ok(())
}
pub fn duration_since_time_of_start(&self) -> Duration {
self.time_of_start.elapsed()
}
pub fn pot_filter_exclude_list(&self) -> Ref<PotFilterExcludes> {
self.global_pot_filter_exclude_list.borrow()
}
pub fn pot_filter_exclude_list_mut(&self) -> RefMut<PotFilterExcludes> {
self.global_pot_filter_exclude_list.borrow_mut()
}
/// Sets a flag that indicates that there's at least one ReaLearn mapping (in any instance)
/// which matched some computer keyboard input in this main loop cycle. This flag will be read
/// and reset a bit later in the same main loop cycle by [`RealearnControlSurfaceMiddleware`].
pub fn set_keyboard_input_match_flag(&self) {
self.was_processing_keyboard_input.set(true);
}
/// Resets the flag which indicates that there was at least one ReaLearn mapping which matched
/// some computer keyboard input. Returns whether the flag was set.
pub fn reset_keyboard_input_match_flag(&self) -> bool {
self.was_processing_keyboard_input.replace(false)
}
/// Returns a static reference to a Lua state, intended to be used in the main thread only!
///
/// This should only be used for Lua stuff like MIDI scripts, where it would be too expensive
/// to create a new Lua state for each single script and too complex to have narrow-scoped
/// lifetimes. For all other situations, a new Lua state should be constructed.
///
/// # Panics
///
/// Panics if not called from main thread.
///
/// # Safety
///
/// If this static reference is passed to other user threads and used there, we are done.
pub unsafe fn main_thread_lua() -> &'static SafeLua {
static LUA: Lazy<Fragile<SafeLua>> = Lazy::new(|| Fragile::new(SafeLua::new().unwrap()));
LUA.get()
}
pub fn source_state() -> &'static RefCell<RealearnSourceState> {
&Backbone::get().source_state
}
pub fn target_state() -> &'static RefCell<RealearnTargetState> {
&Backbone::get().target_state
}
/// Returns the last touched targets (max. one per touchable type, so not much more than a
/// dozen). The most recently touched ones are at the end, so it's ascending order!
pub fn extract_last_touched_targets(&self) -> Vec<ReaperTarget> {
self.last_touched_targets_container
.borrow()
.last_target_touches
.iter()
.map(|t| t.target.clone())
.collect()
}
pub fn find_last_touched_target(
&self,
filter: LastTouchedTargetFilter,
) -> Option<ReaperTarget> {
let container = self.last_touched_targets_container.borrow();
container.find(filter).cloned()
}
pub fn is_superior(&self, instance_id: &UnitId) -> bool {
self.superior_units.borrow().contains(instance_id)
}
pub fn make_superior(&self, instance_id: UnitId) {
self.superior_units.borrow_mut().insert(instance_id);
}
pub fn make_inferior(&self, instance_id: &UnitId) {
self.superior_units.borrow_mut().remove(instance_id);
}
//
// /// Returns and - if necessary - installs an owned Playtime matrix.
// ///
// /// If this instance already contains an owned Playtime matrix, returns it. If not, creates
// /// and installs one, removing a possibly existing foreign matrix reference.
// pub fn get_or_insert_owned_clip_matrix(&mut self) -> &mut playtime_clip_engine::base::Matrix {
// self.create_and_install_owned_clip_matrix_if_necessary();
// self.owned_clip_matrix_mut().unwrap()
// }
// TODO-high-playtime-refactoring Woah, ugly. This shouldn't be here anymore, the design involved and this dirt
// stayed. self is not used. Same with _mut.
/// Grants immutable access to the Playtime matrix defined for the given ReaLearn instance,
/// if one is defined.
///
/// # Errors
///
/// Returns an error if the given instance doesn't have any Playtime matrix defined.
#[cfg(feature = "playtime")]
pub fn with_clip_matrix<R>(
&self,
instance: &SharedInstance,
f: impl FnOnce(&playtime_clip_engine::base::Matrix) -> R,
) -> anyhow::Result<R> {
let instance = instance.borrow();
let matrix = instance.get_playtime_matrix()?;
Ok(f(matrix))
}
/// Grants mutable access to the Playtime matrix defined for the given ReaLearn instance,
/// if one is defined.
#[cfg(feature = "playtime")]
pub fn with_clip_matrix_mut<R>(
&self,
instance: &SharedInstance,
f: impl FnOnce(&mut playtime_clip_engine::base::Matrix) -> R,
) -> anyhow::Result<R> {
let mut instance = instance.borrow_mut();
let matrix = instance.get_playtime_matrix_mut()?;
Ok(f(matrix))
}
pub fn register_instance(&self, id: InstanceId, instance: WeakInstance) {
self.instances.borrow_mut().insert(id, instance);
}
pub(super) fn unregister_instance(&self, id: &InstanceId) {
self.instances.borrow_mut().remove(id);
}
pub fn control_is_allowed(&self, unit_id: &UnitId, control_input: ControlInput) -> bool {
if let Some(dev_input) = control_input.device_input() {
self.interaction_is_allowed(unit_id, dev_input, &self.control_input_usages)
} else {
true
}
}
#[allow(dead_code)]
pub fn find_instance(&self, instance_id: InstanceId) -> Option<SharedInstance> {
let weak_instance_states = self.instances.borrow();
let weak_instance_state = weak_instance_states.get(&instance_id)?;
weak_instance_state.upgrade()
}
/// This should be called whenever the focused FX changes.
///
/// We use this in order to be able to access the previously focused FX at all times.
pub fn notify_fx_focused(&self, new_fx: Option<Fx>) {
self.recently_focused_fx_container.borrow_mut().feed(new_fx);
}
/// Returns the last relevant focused FX even if it's not focused anymore and even if it's closed.
///
/// Returns `None` only if no FX has been focused yet or if the last focused FX doesn't exist anymore.
///
/// One special thing about this is that this doesn't necessarily return the currently focused
/// FX. It could also be the previously focused one. That's important because when queried from ReaLearn UI, the
/// current one is mostly ReaLearn itself - which is in most cases not what we want.
pub fn last_relevant_available_focused_fx(&self, this_realearn_fx: &Fx) -> Option<Fx> {
self.recently_focused_fx_container
.borrow()
.last_relevant_available_fx(this_realearn_fx)
.cloned()
}
pub fn feedback_is_allowed(&self, unit_id: &UnitId, feedback_output: FeedbackOutput) -> bool {
if let Some(dev_output) = feedback_output.device_output() {
self.interaction_is_allowed(unit_id, dev_output, &self.feedback_output_usages)
} else {
true
}
}
/// Also drops all previous usage of that instance.
///
/// Returns true if this actually caused a change in *feedback output* usage.
pub fn update_io_usage(
&self,
instance_id: &UnitId,
control_input: Option<DeviceControlInput>,
feedback_output: Option<DeviceFeedbackOutput>,
) -> bool {
{
let mut usages = self.control_input_usages.borrow_mut();
update_io_usage(&mut usages, instance_id, control_input);
}
{
let mut usages = self.feedback_output_usages.borrow_mut();
update_io_usage(&mut usages, instance_id, feedback_output)
}
}
pub(super) fn notify_target_touched(&self, event: TargetTouchEvent) {
let has_changed = self
.last_touched_targets_container
.borrow_mut()
.update(event);
if has_changed {
self.additional_feedback_event_sender
.send_complaining(AdditionalFeedbackEvent::LastTouchedTargetChanged)
}
}
fn interaction_is_allowed<D: Eq + Hash>(
&self,
instance_id: &UnitId,
device: D,
usages: &RefCell<NonCryptoHashMap<D, NonCryptoHashSet<UnitId>>>,
) -> bool {
let superior_instances = self.superior_units.borrow();
if superior_instances.is_empty() || superior_instances.contains(instance_id) {
// There's no instance living on a higher floor.
true
} else {
// There's at least one instance living on a higher floor and it's not ours.
let usages = usages.borrow();
if let Some(instances) = usages.get(&device) {
if instances.len() <= 1 {
// It's just us using this device (or nobody, but shouldn't happen).
true
} else {
// Other instances use this device as well.
// Allow usage only if none of these instances are on the upper floor.
!instances.iter().any(|id| superior_instances.contains(id))
}
} else {
// No instance using this device (shouldn't happen because at least we use it).
true
}
}
}
}
/// Returns `true` if there was an actual change.
fn update_io_usage<D: Eq + Hash + Copy>(
usages: &mut NonCryptoHashMap<D, NonCryptoHashSet<UnitId>>,
instance_id: &UnitId,
device: Option<D>,
) -> bool {
let mut previously_used_device: Option<D> = None;
for (dev, ids) in usages.iter_mut() {
let was_removed = ids.remove(instance_id);
if was_removed {
previously_used_device = Some(*dev);
}
}
if let Some(dev) = device {
usages
.entry(dev)
.or_default()
.insert(instance_id.to_owned());
}
device != previously_used_device
}
#[derive(Clone, Debug)]
pub struct TargetTouchEvent {
pub target: ReaperTarget,
pub caused_by_realearn: bool,
}
#[derive(Debug, Default)]
struct RecentlyFocusedFxContainer {
previous: Option<Fx>,
current: Option<Fx>,
}
impl RecentlyFocusedFxContainer {
pub fn last_relevant_available_fx(&self, this_realearn_fx: &Fx) -> Option<&Fx> {
[self.current.as_ref(), self.previous.as_ref()]
.into_iter()
.flatten()
.find(|fx| fx.is_available() && *fx != this_realearn_fx)
}
pub fn feed(&mut self, new_fx: Option<Fx>) {
// Never clear any memorized FX.
let Some(new_fx) = new_fx else {
return;
};
// Don't rotate if current FX has not changed.
if let Some(current) = self.current.as_ref() {
if &new_fx == current {
return;
}
}
// Rotate
self.previous = self.current.take();
self.current = Some(new_fx);
}
}
fn poll_stream_deck_messages(
messages: &mut Vec<QualifiedStreamDeckMessage>,
dev_id: StreamDeckDeviceId,
sd: &mut StreamDeck,
button_states: &mut NonCryptoHashMap<StreamDeckDeviceId, Vec<u8>>,
) -> Result<(), streamdeck::Error> {
let old_button_states = button_states.entry(dev_id).or_default();
let new_button_states = sd.read_buttons(None)?;
for (i, new_is_on) in new_button_states.iter().enumerate() {
let old_is_on = old_button_states.get(i).copied().unwrap_or(0);
if *new_is_on == old_is_on {
continue;
}
let msg = QualifiedStreamDeckMessage {
dev_id,
msg: StreamDeckMessage::new(i as u32, *new_is_on > 0),
};
messages.push(msg);
}
*old_button_states = new_button_states;
Ok(())
}
@@ -0,0 +1,320 @@
use crate::base::eel;
use crate::domain::{
CompartmentParamIndex, CompartmentParams, EffectiveParamValue, ExpressionEvaluator, MappingId,
RawParamValue, COMPARTMENT_PARAMETER_COUNT, EXPRESSION_NONE_VALUE,
};
use base::hash_util::NonCryptoHashSet;
use base::regex;
use helgoboss_learn::AbsoluteValue;
use std::error::Error;
#[derive(Debug)]
pub enum ActivationCondition {
Always,
Modifiers(Vec<ModifierCondition>),
Program {
param_index: CompartmentParamIndex,
program_index: u32,
},
// Boxed in order to keep the enum variants at a similar size (clippy gave that hint)
Eel(Box<EelCondition>),
Expression(Box<ExpressionCondition>),
TargetValue {
lead_mapping: Option<MappingId>,
condition: Box<ExpressionEvaluator>,
},
}
impl ActivationCondition {
/// Returns if this activation condition can be affected by parameter changes in general.
pub fn can_be_affected_by_parameters(&self) -> bool {
!matches!(self, ActivationCondition::Always)
}
/// Returns the referenced lead mapping of this activation condition if it's a target-value
/// based one.
pub fn target_value_lead_mapping(&self) -> Option<MappingId> {
match self {
ActivationCondition::TargetValue {
lead_mapping: Some(m),
..
} => Some(*m),
_ => None,
}
}
/// Returns if this activation condition is fulfilled in presence of the given set of
/// parameters.
///
/// Returns `None` if the condition doesn't depend on parameter values (in which case it must
/// be evaluated in other ways).
pub fn is_fulfilled(&self, params: &CompartmentParams) -> Option<bool> {
use ActivationCondition::*;
let res = match self {
Always => true,
Modifiers(conditions) => modifier_conditions_are_fulfilled(conditions, params),
Program {
param_index,
program_index,
} => program_condition_is_fulfilled(*param_index, *program_index, params),
Eel(condition) => {
condition.notify_params_changed(params);
condition.is_fulfilled()
}
Expression(condition) => condition.is_fulfilled(params),
TargetValue { .. } => return None,
};
Some(res)
}
/// Returns `Some` if the given value update affects the mapping's activation state and if the
/// resulting state is on or off.
///
/// Passing a `None` target value means the target is inactive.
pub fn process_target_value_update(
&self,
lead_mapping_id: MappingId,
target_value: Option<AbsoluteValue>,
) -> Option<bool> {
match self {
ActivationCondition::TargetValue {
lead_mapping: Some(rm),
condition,
} if lead_mapping_id == *rm => {
let y = match target_value {
None => EXPRESSION_NONE_VALUE,
Some(v) => v.to_unit_value().get(),
};
let result = condition.evaluate_with_additional_vars(|name, _| match name {
"none" => Some(EXPRESSION_NONE_VALUE),
"y" => Some(y),
_ => None,
});
result.ok().map(|v| v > 0.0)
}
_ => None,
}
}
/// Returns `Some` if the given value update affects the mapping's activation state and if the
/// resulting state is on or off.
///
/// Other parameters in the given array should not have changed! That's especially important
/// for the EEL activation condition which will ignore the other values in the array for
/// performance reasons and just look at the difference (because it has the array already
/// stored in the EEL VM). For performance reasons as well, the other activation condition types
/// don't store anything and read the given parameter array.
///
/// Attention: For EEL condition, this has a side effect!
/// TODO-low This is not visible because it's &self.
pub fn process_param_update(
&self,
params: &CompartmentParams,
// Changed index
index: CompartmentParamIndex,
// Previous value at changed index
previous_value: RawParamValue,
) -> Option<bool> {
use ActivationCondition::*;
let is_fulfilled = match self {
Modifiers(conditions) => {
let is_affected = conditions.iter().any(|c| {
c.is_affected_by_param_change(
index,
previous_value,
params.at(index).raw_value(),
)
});
if !is_affected {
return None;
}
modifier_conditions_are_fulfilled(conditions, params)
}
Program {
param_index,
program_index,
} => {
if index != *param_index {
return None;
}
program_condition_is_fulfilled(*param_index, *program_index, params)
}
Eel(condition) => {
let is_affected = condition
.notify_param_changed(index, params.at(index).effective_value().into());
if !is_affected {
return None;
}
condition.is_fulfilled()
}
Expression(condition) => condition.is_fulfilled(params),
Always => return None,
// This conditional activation doesn't depend on parameter values, it's evaluated
// in other ways.
TargetValue { .. } => return None,
};
Some(is_fulfilled)
}
}
fn modifier_conditions_are_fulfilled(
conditions: &[ModifierCondition],
params: &CompartmentParams,
) -> bool {
conditions
.iter()
.all(|condition| condition.is_fulfilled(params))
}
fn program_condition_is_fulfilled(
param_index: CompartmentParamIndex,
program_index: u32,
params: &CompartmentParams,
) -> bool {
let current_program_index = match params.at(param_index).effective_value() {
EffectiveParamValue::Continuous(v) => {
// If no count given for the parameter, we just assume a count of 100.
(v * 99.0).round() as u32
}
EffectiveParamValue::Discrete(v) => v,
};
current_program_index == program_index
}
fn param_value_is_on(value: f32) -> bool {
value > 0.0
}
#[derive(Debug)]
pub struct ModifierCondition {
param_index: CompartmentParamIndex,
is_on: bool,
}
impl ModifierCondition {
pub fn new(param_index: CompartmentParamIndex, is_on: bool) -> ModifierCondition {
ModifierCondition { param_index, is_on }
}
pub fn is_affected_by_param_change(
&self,
index: CompartmentParamIndex,
previous_value: RawParamValue,
value: RawParamValue,
) -> bool {
self.param_index == index && param_value_is_on(previous_value) != param_value_is_on(value)
}
/// Returns if this activation condition is fulfilled in presence of the given set of
/// parameters.
pub fn is_fulfilled(&self, params: &CompartmentParams) -> bool {
let param_value = params.at(self.param_index).raw_value();
let is_on = param_value_is_on(param_value);
is_on == self.is_on
}
}
#[derive(Debug)]
pub struct ExpressionCondition {
evaluator: ExpressionEvaluator,
}
impl ExpressionCondition {
pub fn compile(expression: &str) -> Result<Self, Box<dyn Error>> {
let condition = Self {
evaluator: ExpressionEvaluator::compile(expression)?,
};
Ok(condition)
}
pub fn is_fulfilled(&self, params: &CompartmentParams) -> bool {
let result = self.evaluator.evaluate_with_params(params);
result.map(|v| v > 0.0).unwrap_or(false)
}
}
#[derive(Debug)]
pub struct EelCondition {
// Declared above VM in order to be dropped before VM is dropped.
program: eel::Program,
// The existence in memory and the Drop is important.
_vm: eel::Vm,
params: [Option<eel::Variable>; COMPARTMENT_PARAMETER_COUNT as usize],
y: eel::Variable,
}
impl EelCondition {
// Compiles the given script and creates an appropriate condition.
pub fn compile(eel_script: &str) -> Result<EelCondition, String> {
if eel_script.trim().is_empty() {
return Err("script empty".to_string());
}
let vm = eel::Vm::new();
let program = vm.compile(eel_script)?;
let y = vm.register_variable("y");
let params = {
let mut array = [None; COMPARTMENT_PARAMETER_COUNT as usize];
for i in extract_used_param_indexes(eel_script).into_iter() {
let variable_name = format!("p{}", i + 1);
let variable = vm.register_variable(&variable_name);
// Set initial value so we can calculate the initial activation result after
// compilation. All subsequent parameter value changes are done incrementally via
// single parameter updates (which is more efficient).
unsafe {
// We initialize this to zero. It will be constantly updated to current values
// in main processor.
variable.set(0.0);
}
array[i as usize] = Some(variable);
}
array
};
Ok(EelCondition {
program,
_vm: vm,
params,
y,
})
}
pub fn notify_params_changed(&self, params: &CompartmentParams) {
for (i, p) in self.params.iter().enumerate() {
let i = CompartmentParamIndex::try_from(i as u32).unwrap();
if let Some(v) = p {
unsafe {
v.set(params.at(i).effective_value().into());
}
}
}
}
/// Returns true if activation might have changed.
pub fn notify_param_changed(&self, param_index: CompartmentParamIndex, value: f64) -> bool {
if let Some(v) = &self.params[param_index.get() as usize] {
unsafe {
v.set(value);
}
true
} else {
false
}
}
pub fn is_fulfilled(&self) -> bool {
let result = unsafe {
self.program.execute();
self.y.get()
};
result > 0.0
}
}
fn extract_used_param_indexes(eel_script: &str) -> NonCryptoHashSet<u32> {
let param_regex = regex!(r"\bp([0-9]+)\b");
param_regex
.captures_iter(eel_script)
.flat_map(|m| m[1].parse())
.filter(|i| *i >= 1 && *i <= COMPARTMENT_PARAMETER_COUNT)
.map(|i: u32| i - 1)
.collect()
}
@@ -0,0 +1,60 @@
use crate::domain::GLOBAL_AUDIO_STATE;
use helgoboss_learn::AbstractTimestamp;
use reaper_common_types::{DurationInSeconds, Hz};
use std::fmt::{Display, Formatter};
use std::ops::Sub;
use std::time::Duration;
pub type ControlEvent<P> = helgoboss_learn::ControlEvent<P, ControlEventTimestamp>;
/// Timestamp of a control event.
//
// Don't expose the inner field, it should stay private. We might swap the time unit in future to
// improve performance and accuracy.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub struct ControlEventTimestamp(Duration);
impl ControlEventTimestamp {
pub fn from_main_thread() -> Self {
let block_count = GLOBAL_AUDIO_STATE.load_block_count();
let block_size = GLOBAL_AUDIO_STATE.load_block_size();
let sample_count = block_count * block_size as u64;
Self::from_rt(
sample_count,
GLOBAL_AUDIO_STATE.load_sample_rate(),
DurationInSeconds::ZERO,
)
}
}
impl AbstractTimestamp for ControlEventTimestamp {
fn duration(&self) -> Duration {
self.0
}
}
impl ControlEventTimestamp {
pub fn from_rt(
sample_count: u64,
sample_rate: Hz,
intra_block_offset: DurationInSeconds,
) -> Self {
let start_secs = sample_count as f64 / sample_rate.get();
let final_secs = start_secs + intra_block_offset.get();
Self(Duration::from_secs_f64(final_secs))
}
}
impl Sub for ControlEventTimestamp {
type Output = Duration;
fn sub(self, rhs: Self) -> Self::Output {
self.0.saturating_sub(rhs.0)
}
}
impl Display for ControlEventTimestamp {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self.0)
}
}
@@ -0,0 +1,985 @@
use crate::domain::{
Backbone, ControlEvent, ControlEventTimestamp, DeviceControlInput, DeviceDiff,
DeviceFeedbackOutput, DomainEventHandler, FeedbackOutput, FinalSourceFeedbackValue, InstanceId,
MainProcessor, MidiDeviceChangeDetector, MidiDeviceChangePayload,
MonitoringFxChainChangeDetector, OscDeviceId, OscInputDevice, OscScanResult,
QualifiedInstanceEvent, ReaperConfigChangeDetector, ReaperMessage, ReaperTarget,
SharedInstance, SharedMainProcessors, StreamDeckDevicePayload, TargetTouchEvent,
TouchedTrackParameterType, UnitEvent, UnitId, WeakInstance,
};
use base::{metrics_util, Global, NamedChannelSender, SenderToNormalThread};
use crossbeam_channel::Receiver;
use reaper_high::{
ChangeDetectionMiddleware, ChangeEvent, ControlSurfaceEvent, ControlSurfaceMiddleware,
FutureMiddleware, Fx, FxParameter, MainTaskMiddleware, Project, Reaper,
};
use reaper_rx::ControlSurfaceRxMiddleware;
use rosc::{OscMessage, OscPacket};
use std::cell::RefCell;
use base::hash_util::{NonCryptoHashMap, NonCryptoIndexMap};
use base::metrics_util::measure_time;
use itertools::{EitherOrBoth, Itertools};
use reaper_medium::{
CommandId, ExtSupportsExtendedTouchArgs, GetFocusedFx2Result, GetTouchStateArgs,
GetTouchedOrFocusedFxCurrentlyFocusedFxResult, MediaTrack, MidiInputDeviceId,
MidiOutputDeviceId, PositionInSeconds, ReaProject, ReaperNormalizedFxParamValue,
SectionContext,
};
use rxrust::prelude::*;
use std::fmt::Debug;
use std::mem;
use tracing::debug;
type OscCaptureSender = async_channel::Sender<OscScanResult>;
type TargetCaptureSender = async_channel::Sender<TargetTouchEvent>;
const CONTROL_SURFACE_MAIN_TASK_BULK_SIZE: usize = 10;
const INSTANCE_EVENT_BULK_SIZE: usize = 30;
const ADDITIONAL_FEEDBACK_EVENT_BULK_SIZE: usize = 30;
const INSTANCE_ORCHESTRATION_EVENT_BULK_SIZE: usize = 30;
const OSC_INCOMING_BULK_SIZE: usize = 1000;
#[derive(Debug)]
pub struct RealearnControlSurfaceMiddleware<EH: DomainEventHandler> {
change_detection_middleware: ChangeDetectionMiddleware,
change_event_queue: RefCell<Vec<ChangeEvent>>,
monitoring_fx_chain_change_detector: MonitoringFxChainChangeDetector,
rx_middleware: ControlSurfaceRxMiddleware,
instances: NonCryptoIndexMap<InstanceId, WeakInstance>,
main_processors: SharedMainProcessors<EH>,
main_task_receiver: Receiver<RealearnControlSurfaceMainTask<EH>>,
instance_event_receiver: Receiver<QualifiedInstanceEvent>,
#[cfg(feature = "playtime")]
playtime: PlaytimeMiddleware,
additional_feedback_event_receiver: Receiver<AdditionalFeedbackEvent>,
instance_orchestration_event_receiver: Receiver<UnitOrchestrationEvent>,
main_task_middleware: MainTaskMiddleware,
future_middleware: FutureMiddleware,
counter: u64,
full_beats: NonCryptoHashMap<ReaProject, u32>,
deprecated_fx_focus_state: Option<GetFocusedFx2Result>,
_modern_fx_focus_state: Option<GetTouchedOrFocusedFxCurrentlyFocusedFxResult>,
target_capture_senders: NonCryptoHashMap<Option<UnitId>, TargetCaptureSender>,
osc_capture_sender: Option<OscCaptureSender>,
osc_input_devices: Vec<OscInputDevice>,
device_change_detector: MidiDeviceChangeDetector,
reaper_config_change_detector: ReaperConfigChangeDetector,
control_surface_event_sender: SenderToNormalThread<ControlSurfaceEvent<'static>>,
control_surface_event_receiver: crossbeam_channel::Receiver<ControlSurfaceEvent<'static>>,
#[cfg(debug_assertions)]
last_undesired_allocation_count: u32,
event_handler: Box<dyn ControlSurfaceEventHandler>,
osc_buffer: Vec<OscPacket>,
}
#[cfg(feature = "playtime")]
#[derive(Debug)]
struct PlaytimeMiddleware {
clip_matrix_event_receiver: Receiver<crate::domain::QualifiedClipMatrixEvent>,
}
pub trait ControlSurfaceEventHandler: Debug {
fn midi_input_devices_changed(
&self,
diff: &DeviceDiff<MidiInputDeviceId>,
device_config_changed: bool,
);
fn midi_output_devices_changed(
&self,
diff: &DeviceDiff<MidiOutputDeviceId>,
device_config_changed: bool,
);
fn process_reaper_change_events(&self, change_events: &[ChangeEvent]);
}
#[allow(clippy::large_enum_variant)]
pub enum RealearnControlSurfaceMainTask<EH: DomainEventHandler> {
AddInstance(InstanceId, WeakInstance),
AddMainProcessor(MainProcessor<EH>),
RemoveMainProcessor(UnitId),
StartCapturingTargets(Option<UnitId>, TargetCaptureSender),
StopCapturingTargets(Option<UnitId>),
StartCapturingOsc(OscCaptureSender),
StopCapturingOsc,
SendAllFeedback,
}
/// Not all events in REAPER are communicated via a control surface, e.g. action invocations.
#[derive(Debug)]
pub enum AdditionalFeedbackEvent {
ActionInvoked(ActionInvokedEvent),
FxSnapshotLoaded(FxSnapshotLoadedEvent),
/// Work around REAPER's inability to notify about parameter changes in
/// monitoring FX by simulating the notification ourselves.
/// Then parameter learning and feedback works at least for
/// ReaLearn monitoring FX instances, which is especially
/// useful for conditional activation.
RealearnMonitoringFxParameterValueChanged(RealearnMonitoringFxParameterValueChangedEvent),
ParameterAutomationTouchStateChanged(ParameterAutomationTouchStateChangedEvent),
/// Beat-changed events are emitted only when the project is playing.
///
/// We shouldn't change that because targets such as "Marker/region: Go to" or "Project: Seek"
/// depend on this (see https://github.com/helgoboss/helgobox/issues/663).
BeatChanged(BeatChangedEvent),
MappedFxParametersChanged,
/// This event is raised when an FX window loses focus and a non-FX window gains focus,
/// and vice versa (not covered by FxFocused change event).
///
/// Attention: This will only be fired for REAPER 7+!
FocusSwitchedBetweenMainAndFx,
/// Forwarded unit state event
///
/// Not all unit events are forwarded, only those that might matter for other
/// units.
Unit {
unit_id: UnitId,
unit_event: UnitEvent,
},
LastTouchedTargetChanged,
}
#[derive(Debug)]
pub enum UnitOrchestrationEvent {
/// Sent by a ReaLearn instance X if it releases control over a source.
///
/// This enables other instances to take over control of that source before X finally "switches
/// off lights".
SourceReleased(SourceReleasedEvent),
/// Whenever something about instance's device usage changes (either input or output or both
/// potentially change).
IoUpdated(IoUpdatedEvent),
}
/// Communicates changes in which input and output device a ReaLearn instance uses or used.
#[derive(Debug)]
pub struct IoUpdatedEvent {
pub unit_id: UnitId,
pub control_input: Option<DeviceControlInput>,
pub control_input_used: bool,
pub feedback_output: Option<DeviceFeedbackOutput>,
pub feedback_output_used: bool,
pub feedback_output_usage_might_have_changed: bool,
}
#[derive(Debug)]
pub struct SourceReleasedEvent {
pub unit_id: UnitId,
pub feedback_output: Option<FeedbackOutput>,
pub feedback_value: FinalSourceFeedbackValue,
}
#[derive(Debug)]
pub struct BeatChangedEvent {
pub project: Project,
pub new_value: PositionInSeconds,
}
#[derive(Debug)]
pub struct ActionInvokedEvent {
pub section_context: SectionContext<'static>,
pub command_id: CommandId,
}
#[derive(Debug)]
pub struct FxSnapshotLoadedEvent {
pub fx: Fx,
}
#[derive(Debug)]
pub struct RealearnMonitoringFxParameterValueChangedEvent {
pub parameter: FxParameter,
pub new_value: ReaperNormalizedFxParamValue,
}
#[derive(Debug)]
pub struct ParameterAutomationTouchStateChangedEvent {
pub track: MediaTrack,
pub parameter_type: TouchedTrackParameterType,
pub new_value: bool,
}
impl<EH: DomainEventHandler> RealearnControlSurfaceMiddleware<EH> {
#[allow(clippy::too_many_arguments)]
pub fn new(
main_task_receiver: Receiver<RealearnControlSurfaceMainTask<EH>>,
instance_event_receiver: Receiver<QualifiedInstanceEvent>,
#[cfg(feature = "playtime")] clip_matrix_event_receiver: Receiver<
crate::domain::QualifiedClipMatrixEvent,
>,
additional_feedback_event_receiver: Receiver<AdditionalFeedbackEvent>,
instance_orchestration_event_receiver: Receiver<UnitOrchestrationEvent>,
main_processors: SharedMainProcessors<EH>,
event_handler: Box<dyn ControlSurfaceEventHandler>,
) -> Self {
let mut device_change_detector = MidiDeviceChangeDetector::new();
// Prevent change messages to be sent on load by polling one time and ignoring result.
device_change_detector.poll_for_midi_input_device_changes();
device_change_detector.poll_for_midi_output_device_changes();
let (control_surface_event_sender, control_surface_event_receiver) =
SenderToNormalThread::new_unbounded_channel("control surface events");
Self {
change_detection_middleware: ChangeDetectionMiddleware::new(),
change_event_queue: RefCell::new(Vec::with_capacity(100)),
monitoring_fx_chain_change_detector: Default::default(),
rx_middleware: ControlSurfaceRxMiddleware::new(Global::control_surface_rx().clone()),
instances: Default::default(),
main_processors,
main_task_receiver,
instance_event_receiver,
#[cfg(feature = "playtime")]
playtime: PlaytimeMiddleware {
clip_matrix_event_receiver,
},
additional_feedback_event_receiver,
instance_orchestration_event_receiver,
main_task_middleware: Global::get().create_task_support_middleware(),
future_middleware: Global::get().create_future_support_middleware(),
counter: 0,
full_beats: Default::default(),
deprecated_fx_focus_state: Default::default(),
_modern_fx_focus_state: Default::default(),
target_capture_senders: Default::default(),
osc_capture_sender: None,
osc_input_devices: vec![],
device_change_detector,
reaper_config_change_detector: Default::default(),
control_surface_event_sender,
control_surface_event_receiver,
#[cfg(debug_assertions)]
last_undesired_allocation_count: 0,
event_handler,
osc_buffer: Default::default(),
}
}
fn run_internal(&mut self) {
let timestamp = ControlEventTimestamp::from_main_thread();
#[cfg(debug_assertions)]
{
// TODO-medium-playtime-refactoring This is propagated using main processors but it's a global event. We
// should use the global control surface event handler for this! Or ShutdownDetectionPanel? After all,
// this is not needed in the domain! But not urgent. It's enabled in dev builds only.
let current_undesired_allocation_count =
helgobox_allocator::undesired_allocation_count();
if current_undesired_allocation_count != self.last_undesired_allocation_count {
self.last_undesired_allocation_count = current_undesired_allocation_count;
let event = &crate::domain::InternalInfoEvent::UndesiredAllocationCountChanged;
for p in self.main_processors.borrow_mut().iter_mut() {
p.process_info_event(event);
}
}
}
// Poll for change events that don't support notification. At the moment we execute this
// only once per run() invocation to save resources. As a consequence, we can't detect
// whether the changes were caused by ReaLearn targets or direct interaction with REAPER
// (e.g. via mouse). However, since change events detected by polling are currently not
// used for target learning / last touched target detection anyway, we don't need to know
// about their cause.
self.poll_for_more_change_events();
// Process REAPER events that occurred since the last call of run(). All events
// accumulated up to this point are most likely *not* caused by ReaLearn, but by direct
// interaction with REAPER, e.g. changing track volume with the mouse.
// However, there's one exception: At least one ReaLearn instance might have input set to
// "Computer keyboard" and therefore process key strokes. In this case, the keyboard
// processing chain might have run before and caused ReaLearn to invoke some target.
// In this case, the backbone knows about it.
let realearn_was_matching_keyboard_input =
Backbone::get().reset_keyboard_input_match_flag();
self.process_events(realearn_was_matching_keyboard_input);
// Execute operations scheduled by ReaLearn (in different ways)
self.main_task_middleware.run();
self.future_middleware.run();
self.rx_middleware.run();
self.process_main_tasks();
self.process_instance_orchestration_events();
// Inform ReaLearn about various changes that are not relevant for target learning
self.detect_reaper_config_changes();
self.emit_focus_switch_between_main_and_fx_as_feedback_event();
self.emit_instance_events();
self.emit_stream_deck_events(timestamp);
self.emit_beats_as_feedback_events();
self.detect_device_changes(timestamp);
self.process_incoming_osc_messages(timestamp);
// Drive Playtime
#[cfg(feature = "playtime")]
{
self.poll_playtime();
self.process_incoming_clip_matrix_events();
}
// Finally let the ReaLearn main processors do their regular job (the instances)
self.run_main_processors(timestamp);
// Some control surface events might have been picked up during this call of run() while
// the change event queue was borrowed. Process them now (most likely they are turned into
// change events).
self.process_deferred_control_surface_events();
// Process REAPER events that were accumulated within this call of run(). All events
// accumulated up to this point are caused by ReaLearn itself.
self.process_events(true);
self.counter += 1;
}
pub fn remove_instance(&mut self, id: InstanceId) {
self.instances.remove(&id);
}
pub fn remove_main_processor(&mut self, id: UnitId) -> anyhow::Result<()> {
self.main_processors
.try_borrow_mut()?
.retain(|p| p.unit_id() != id);
Ok(())
}
pub fn set_osc_input_devices(&mut self, devs: Vec<OscInputDevice>) {
self.osc_input_devices = devs;
}
pub fn shutdown(&self) {
for m in &mut *self.main_processors.borrow_mut() {
m.switch_lights_off();
}
}
pub fn clear_osc_input_devices(&mut self) {
self.osc_input_devices.clear();
}
/// Called when waking up ReaLearn (first instance appears again or the first time).
pub fn wake_up(&self) {
let mut change_events = vec![];
self.change_detection_middleware.reset(|e| {
change_events.push(e);
});
for i in self.instances() {
i.borrow_mut()
.process_control_surface_change_events(&change_events);
}
for m in &*self.main_processors.borrow() {
m.process_control_surface_change_events(&change_events);
}
for e in change_events {
self.rx_middleware.handle_change(e);
}
// We don't want to execute tasks which accumulated during the "downtime" of Reaper.
// So we just consume all without executing them.
self.main_task_middleware.reset();
self.future_middleware.reset();
}
fn process_events(&mut self, caused_by_realearn: bool) {
self.process_change_events(caused_by_realearn);
self.process_incoming_additional_feedback(caused_by_realearn);
}
fn process_change_events(&mut self, caused_by_realearn: bool) {
// Our goal is to always keep using the same change event queue vector so that we don't
// have to reallocate on each main loop cycle. That's also why we use `drain` further down.
// Previously, we just directly used the `change_event_queue` variable. However, an
// issue occurred: https://github.com/helgoboss/helgobox/issues/672#issuecomment-1246997201.
// A user got a panic while the main processors processed the change events ... which is not
// optimal but can happen in certain cases that are a topic for another day, see the
// comment. The issue we need to deal with here is another one: The same panic occurred
// until eternity because at the next main loop cycle the same event was still in the queue!
// That's exactly the reason why we now temporarily replace `change_event_queue` with an
// empty vector (doesn't require allocation) and replace it again with the old vector
// when everything run through successfully. To my knowledge, this is the only place where
// we need this logic because everywhere else we use channels, which have this logic baked
// in already ("pop instead of peek").
// Long story short: The following line ensures we make a "pop" instead of a "peek" in order
// to avoid an infinite loop in case of a panic.
let mut normal_events = self.change_event_queue.replace(vec![]);
// Important because we deferred the change event handling, so it could be invalid now!
// See https://github.com/helgoboss/helgobox/issues/672.
normal_events.retain(|e| e.is_still_valid());
let monitoring_fx_events = metrics_util::measure_time(
"helgobox.control_surface.detect_monitoring_fx_changes",
|| self.monitoring_fx_chain_change_detector.poll_for_changes(),
);
if normal_events.is_empty() && monitoring_fx_events.is_empty() {
return;
}
self.event_handler
.process_reaper_change_events(&normal_events);
for i in self.instances() {
i.borrow_mut()
.process_control_surface_change_events(&normal_events);
}
// This is for feedback processing. No Rx!
let main_processors = self.main_processors.borrow();
for p in main_processors.iter() {
p.process_control_surface_change_events(&normal_events);
p.process_control_surface_change_events(&monitoring_fx_events);
}
// The rest is only for upper layers (e.g. UI), not for processing.
for e in normal_events
.drain(..)
.chain(monitoring_fx_events.into_iter())
{
self.rx_middleware.handle_change(e.clone());
if let Some(target) = ReaperTarget::touched_from_change_event(e) {
process_touched_target(target, caused_by_realearn, &self.target_capture_senders);
}
}
// Now that everything ran successfully, we can assign the old drained vector back to the
// original event queue. This ensures we can keep using the previous memory allocation.
*self.change_event_queue.borrow_mut() = normal_events;
}
fn process_deferred_control_surface_events(&self) {
while let Ok(event) = self.control_surface_event_receiver.try_recv() {
let mut change_event_queue = self.change_event_queue.borrow_mut();
self.handle_event_internal(&event, &mut change_event_queue);
}
}
fn process_main_tasks(&mut self) {
for t in self
.main_task_receiver
.try_iter()
.take(CONTROL_SURFACE_MAIN_TASK_BULK_SIZE)
{
use RealearnControlSurfaceMainTask::*;
match t {
AddInstance(id, instance) => {
self.instances.insert(id, instance);
}
AddMainProcessor(p) => {
self.main_processors.borrow_mut().push(p);
}
RemoveMainProcessor(unit_id) => {
self.main_processors
.try_borrow_mut()
.expect("asynchronous removal of main processor also failed")
.retain(|p| p.unit_id() != unit_id);
}
StartCapturingTargets(instance_id, sender) => {
self.target_capture_senders.insert(instance_id, sender);
}
StopCapturingTargets(instance_id) => {
self.target_capture_senders.remove(&instance_id);
}
StartCapturingOsc(sender) => {
self.osc_capture_sender = Some(sender);
}
StopCapturingOsc => {
self.osc_capture_sender = None;
}
SendAllFeedback => {
for m in &*self.main_processors.borrow() {
m.send_all_feedback();
}
}
}
}
}
#[cfg(feature = "playtime")]
fn poll_playtime(&mut self) {
// Poll Playtime engine
playtime_clip_engine::PlaytimeEngine::get().poll();
// Poll all Playtime matrixes
for instance in self.instances() {
let (instance_id, events) = {
let mut instance = instance.borrow_mut();
(instance.id(), instance.poll_owned_clip_matrix())
};
for processor in &*self.main_processors.borrow() {
processor.process_polled_clip_matrix_events(instance_id, &events);
}
}
}
fn instances(&self) -> impl Iterator<Item = SharedInstance> + '_ {
self.instances.values().filter_map(|i| i.upgrade())
}
fn run_main_processors(&mut self, timestamp: ControlEventTimestamp) {
for p in &mut *self.main_processors.borrow_mut() {
p.run_essential(timestamp);
p.run_control(timestamp);
}
}
fn process_incoming_additional_feedback(&mut self, caused_by_realearn: bool) {
for event in self
.additional_feedback_event_receiver
.try_iter()
.take(ADDITIONAL_FEEDBACK_EVENT_BULK_SIZE)
{
if let AdditionalFeedbackEvent::RealearnMonitoringFxParameterValueChanged(e) = &event {
let rx = Global::control_surface_rx();
rx.fx_parameter_value_changed
.borrow_mut()
.next(e.parameter.clone());
rx.fx_parameter_touched
.borrow_mut()
.next(e.parameter.clone());
}
for p in &mut *self.main_processors.borrow_mut() {
p.process_additional_feedback_event(&event)
}
if let Some(target) = ReaperTarget::touched_from_additional_event(&event) {
process_touched_target(target, caused_by_realearn, &self.target_capture_senders);
}
}
}
#[cfg(feature = "playtime")]
fn process_incoming_clip_matrix_events(&mut self) {
for event in self.playtime.clip_matrix_event_receiver.try_iter().take(30) {
for i in self.instances() {
i.borrow().process_non_polled_clip_matrix_event(&event);
}
for p in &mut *self.main_processors.borrow_mut() {
p.process_non_polled_clip_matrix_event(&event);
}
}
}
fn process_instance_orchestration_events(&mut self) {
for event in self
.instance_orchestration_event_receiver
.try_iter()
.take(INSTANCE_ORCHESTRATION_EVENT_BULK_SIZE)
{
use UnitOrchestrationEvent::*;
match event {
SourceReleased(e) => {
debug!("Source of unit {} released", e.unit_id);
// We also allow the instance to take over which released the source in
// the first place! Simply because in the meanwhile, this instance
// could have found a new usage for it! E.g. likely to happen with
// preset changes.
let other_instance_took_over = self
.main_processors
.borrow()
.iter()
.any(|p| p.maybe_takeover_source(&e));
if !other_instance_took_over {
if let Some(p) = self
.main_processors
.borrow()
.iter()
.find(|p| p.unit_id() == e.unit_id)
{
// Finally safe to switch off lights!
p.finally_switch_off_source(e.feedback_output, e.feedback_value);
}
}
}
IoUpdated(e) => {
let backbone_state = Backbone::get();
let feedback_dev_usage_changed = backbone_state.update_io_usage(
&e.unit_id,
if e.control_input_used {
e.control_input
} else {
None
},
if e.feedback_output_used {
e.feedback_output
} else {
None
},
);
if feedback_dev_usage_changed && backbone_state.is_superior(&e.unit_id) {
debug!(
"Superior unit {} {} feedback output",
e.unit_id,
if e.feedback_output_used {
"claimed"
} else {
"released"
}
);
if let Some(feedback_output) = e.feedback_output {
// Give inferior instances the chance to cancel or reactivate.
self.main_processors
.borrow()
.iter()
.filter(|p| p.unit_id() != e.unit_id)
.for_each(|p| {
p.handle_change_of_some_upper_floor_instance(feedback_output)
});
}
}
}
}
}
}
fn detect_reaper_config_changes(&mut self) {
let changes = self.reaper_config_change_detector.poll_for_changes();
for p in &*self.main_processors.borrow() {
p.process_reaper_config_changes(&changes);
}
}
fn emit_focus_switch_between_main_and_fx_as_feedback_event(&mut self) {
let pointers = Reaper::get().medium_reaper().low().pointers();
// TODO The modern GetTouchedOrFocusedFX way needs more testing. I tried to use it hoping it would fix
// https://github.com/helgoboss/helgobox/issues/1367 but it essentially behaves the same
// as GetFocusedFX2, so there's no urge to migrate.
// let fire = if pointers.GetTouchedOrFocusedFX.is_some() {
// // The latest and greatest way to detect focused FX
// self.detect_focus_switch_between_main_and_fx_as_feedback_event_modern()
// } else
let fire = if pointers.GetFocusedFX2.is_some() {
// The deprecated way to detect focused FX
self.detect_focus_switch_between_main_and_fx_as_feedback_event_deprecated()
} else {
// Detection of unfocusing FX (without focusing a new one) not supported in REAPER versions < 7
false
};
if fire {
let event = AdditionalFeedbackEvent::FocusSwitchedBetweenMainAndFx;
for p in &mut *self.main_processors.borrow_mut() {
p.process_additional_feedback_event(&event);
}
}
}
#[allow(unused)]
fn detect_focus_switch_between_main_and_fx_as_feedback_event_modern(&mut self) -> bool {
let reaper = Reaper::get().medium_reaper();
let new = reaper.get_touched_or_focused_fx_currently_focused_fx();
let last = mem::replace(&mut self._modern_fx_focus_state, new);
match (last, new) {
(None, None) => {
// This happens continuously before any FX is focused
false
}
(None, Some(_)) => {
// The first time an FX is focused
true
}
(Some(_), None) => {
// Shouldn't happen because REAPER usually doesn't clear the last-focused FX. But if it happens,
// we should fire.
true
}
(Some(last), Some(new)) => {
// Only fire if we changed from "no FX focused" to "FX focused" or vice versa (not when focusing
// between FX ... this is detected better by REAPER's built-in control surface FxFocused event,
// mainly because the latter doesn't fire when an FX is removed - which would be too much)
last.is_still_focused != new.is_still_focused
}
}
}
#[allow(deprecated)]
fn detect_focus_switch_between_main_and_fx_as_feedback_event_deprecated(&mut self) -> bool {
let reaper = Reaper::get().medium_reaper();
let new = reaper.get_focused_fx_2();
let last = mem::replace(&mut self.deprecated_fx_focus_state, new);
match (last, new) {
(None, None) => {
// This happens continuously before any FX is focused
false
}
(None, Some(_)) => {
// The first time an FX is focused
true
}
(Some(_), None) => {
// Shouldn't happen because REAPER usually doesn't clear the last-focused FX. But if it happens,
// we should fire.
true
}
(Some(last), Some(new)) => {
// Only fire if we changed from "no FX focused" to "FX focused" or vice versa (not when focusing
// between FX ... this is detected better by REAPER's built-in control surface FxFocused event,
// mainly because the latter doesn't fire when an FX is removed - which would be too much)
last.is_still_focused != new.is_still_focused
}
}
}
fn emit_instance_events(&mut self) {
for event in self
.instance_event_receiver
.try_iter()
.take(INSTANCE_EVENT_BULK_SIZE)
{
for p in &mut *self.main_processors.borrow_mut() {
p.process_instance_event_for_feedback(&event)
}
}
}
fn emit_stream_deck_events(&mut self, timestamp: ControlEventTimestamp) {
let backbone = Backbone::get();
for msg in backbone.poll_stream_deck_messages() {
for p in &mut *self.main_processors.borrow_mut() {
if !p.wants_stream_deck_input_from(msg.dev_id) {
continue;
}
let event = ControlEvent::new(msg.msg, timestamp);
p.process_incoming_stream_deck_msg(event);
}
}
}
fn emit_beats_as_feedback_events(&mut self) {
for project in Reaper::get().projects() {
let reference_pos = if project.is_playing() {
project.play_position_latency_compensated()
} else {
project.edit_cursor_position().unwrap_or_default()
};
if self.record_possible_beat_change(project, reference_pos) {
let event = AdditionalFeedbackEvent::BeatChanged(BeatChangedEvent {
project,
new_value: reference_pos,
});
for p in &mut *self.main_processors.borrow_mut() {
p.process_additional_feedback_event(&event);
}
}
}
}
fn detect_device_changes(&mut self, timestamp: ControlEventTimestamp) {
// Check roughly every 2 seconds
if self.counter % (30 * 2) != 0 {
return;
}
// Stream deck
let added_stream_deck_device_ids = Backbone::get().detect_stream_deck_device_changes();
// MIDI
let midi_in_diff = self
.device_change_detector
.poll_for_midi_input_device_changes();
let midi_out_diff = self
.device_change_detector
.poll_for_midi_output_device_changes();
// Resetting MIDI devices is necessary especially on Windows.
reset_midi_devices(
midi_in_diff.added_devices.iter().copied(),
midi_out_diff.added_devices.iter().copied(),
);
// Pass events to event handler
if midi_in_diff.devices_changed() || midi_in_diff.device_config_changed {
self.event_handler
.midi_input_devices_changed(&midi_in_diff, midi_in_diff.device_config_changed);
}
if midi_out_diff.devices_changed() || midi_out_diff.device_config_changed {
self.event_handler
.midi_output_devices_changed(&midi_out_diff, midi_out_diff.device_config_changed);
}
// Emit as REAPER source messages
let mut msg = Vec::with_capacity(2);
if !midi_in_diff.added_devices.is_empty() || !midi_out_diff.added_devices.is_empty() {
let payload = MidiDeviceChangePayload {
input_devices: midi_in_diff.added_devices,
output_devices: midi_out_diff.added_devices,
};
msg.push(ReaperMessage::MidiDevicesConnected(payload));
}
if !midi_in_diff.removed_devices.is_empty() || !midi_out_diff.removed_devices.is_empty() {
let payload = MidiDeviceChangePayload {
input_devices: midi_in_diff.removed_devices,
output_devices: midi_out_diff.removed_devices,
};
msg.push(ReaperMessage::MidiDevicesDisconnected(payload));
}
if !added_stream_deck_device_ids.is_empty() {
msg.push(ReaperMessage::StreamDeckDevicesConnected(
StreamDeckDevicePayload {
devices: added_stream_deck_device_ids,
},
));
}
// Inform main processors
for p in &mut *self.main_processors.borrow_mut() {
for msg in &msg {
let evt = ControlEvent::new(msg, timestamp);
p.process_reaper_message(evt);
}
}
}
fn process_incoming_osc_messages(&mut self, timestamp: ControlEventTimestamp) {
for dev in &mut self.osc_input_devices {
self.osc_buffer.clear();
self.osc_buffer
.extend(dev.poll_multiple(OSC_INCOMING_BULK_SIZE));
for proc in &mut *self.main_processors.borrow_mut() {
if proc.wants_osc_from(dev.id()) {
for packet in &self.osc_buffer {
let evt = ControlEvent::new(packet, timestamp);
proc.process_incoming_osc_packet(evt);
}
}
}
if let Some(sender) = &self.osc_capture_sender {
for packet in self.osc_buffer.drain(..) {
process_incoming_osc_packet_for_learning(*dev.id(), sender, packet)
}
}
}
}
fn handle_event_internal(
&self,
event: &ControlSurfaceEvent,
change_event_queue: &mut Vec<ChangeEvent>,
) -> bool {
// We always need to forward to the change detection middleware even if we are in
// a mode in which the detected change event doesn't matter!
self.change_detection_middleware.process(event, |e| {
// Notify backbone whenever focused FX changes
if let ChangeEvent::FxFocused(evt) = &e {
Backbone::get().notify_fx_focused(evt.fx.clone());
}
// We don't process change events immediately in order to be able to process
// multiple events occurring in one main loop cycle as a natural batch. This
// is important for performance reasons
// (see https://github.com/helgoboss/helgobox/issues/553).
change_event_queue.push(e);
})
}
fn record_possible_beat_change(
&mut self,
project: Project,
reference_pos: PositionInSeconds,
) -> bool {
let beat_info = project.beat_info_at(reference_pos);
let new_full_beats = beat_info.full_beats.get() as _;
let full_beats = self.full_beats.entry(project.raw()).or_default();
let beat_changed = new_full_beats != *full_beats;
*full_beats = new_full_beats;
beat_changed
}
fn poll_for_more_change_events(&mut self) {
let mut change_event_queue = self.change_event_queue.borrow_mut();
measure_time(
"helgobox.control_surface.poll_for_more_change_events",
|| {
self.change_detection_middleware.run(&mut |change_event| {
change_event_queue.push(change_event);
});
},
);
}
}
impl<EH: DomainEventHandler> ControlSurfaceMiddleware for RealearnControlSurfaceMiddleware<EH> {
fn run(&mut self) {
// Already-borrowed / reentrancy check
if self.main_processors.try_borrow_mut().is_err() {
// Main processors area already borrowed! That's possible in some rare cases.
// In any case, we need to skip processing. Otherwise, mutable borrow panics would occur!
//
// Example:
// - Preferences => Audio => Recording: Set "Prompt to save/delete/rename new files" to "on stop"
// - Add mapping that maps key "S" to transport stop action
// - Start playback
// - User presses key "S"
// - RealearnAccelerator borrows the main processors mutably to process the "S" key press to
// - Mapping triggers transport stop
// - REAPER shows the modal dialog (prompt)
// - The main loop continues running on top of the function stack, continuously invoking the run() function
// - BOOM
tracing::warn!("Main processors borrowed already. Skipping processing.");
return;
}
// Run
measure_time("helgobox.control_surface.run", || {
self.run_internal();
});
}
fn handle_event(&self, event: ControlSurfaceEvent) -> bool {
// TODO-high-playtime-refactoring We should do this in reaper-medium (in a more generic way) as soon as it turns
// out to work nicely. Related to this: https://github.com/helgoboss/reaper-rs/issues/54
match self.change_event_queue.try_borrow_mut() {
Ok(mut queue) => self.handle_event_internal(&event, &mut queue),
Err(_) => {
// When we can't borrow the control surface event queue because of reentrancy,
// we need to defer its processing.
self.control_surface_event_sender
.send_complaining(event.clone().into_owned());
false
}
}
}
fn get_touch_state(&self, args: GetTouchStateArgs) -> bool {
if let Ok(domain_type) = TouchedTrackParameterType::try_from_reaper(args.parameter_type) {
Backbone::target_state()
.borrow()
.automation_parameter_is_touched(args.track, domain_type)
} else {
false
}
}
fn ext_supports_extended_touch(&self, _: ExtSupportsExtendedTouchArgs) -> i32 {
1
}
}
fn process_incoming_osc_packet_for_learning(
dev_id: OscDeviceId,
sender: &OscCaptureSender,
packet: OscPacket,
) {
match packet {
OscPacket::Message(msg) => process_incoming_osc_message_for_learning(dev_id, sender, msg),
OscPacket::Bundle(bundle) => {
for p in bundle.content.into_iter() {
process_incoming_osc_packet_for_learning(dev_id, sender, p);
}
}
}
}
fn process_incoming_osc_message_for_learning(
dev_id: OscDeviceId,
sender: &OscCaptureSender,
message: OscMessage,
) {
let scan_result = OscScanResult {
message,
dev_id: Some(dev_id),
};
let _ = sender.try_send(scan_result);
}
fn reset_midi_devices(
in_devs: impl Iterator<Item = MidiInputDeviceId>,
out_devs: impl Iterator<Item = MidiOutputDeviceId>,
) {
let reaper_low = Reaper::get().medium_reaper().low();
if reaper_low.pointers().midi_init.is_none() {
// REAPER version < 6.47
return;
}
for res in in_devs.zip_longest(out_devs) {
let (input_arg, output_arg) = match res {
EitherOrBoth::Both(i, o) => (i.get() as i32, o.get() as i32),
EitherOrBoth::Left(i) => (i.get() as i32, -1),
EitherOrBoth::Right(o) => (-1, o.get() as i32),
};
reaper_low.midi_init(input_arg, output_arg);
}
}
fn process_touched_target(
target: ReaperTarget,
caused_by_realearn: bool,
target_capture_senders: &NonCryptoHashMap<Option<UnitId>, TargetCaptureSender>,
) {
let touch_event = TargetTouchEvent {
target,
caused_by_realearn,
};
for sender in target_capture_senders.values() {
let _ = sender.try_send(touch_event.clone());
}
Backbone::get().notify_target_touched(touch_event);
}
@@ -0,0 +1,78 @@
use crate::domain::{MidiInDevsConfig, MidiOutDevsConfig};
use base::hash_util::NonCryptoHashSet;
use reaper_high::Reaper;
use reaper_medium::{MidiInputDeviceId, MidiOutputDeviceId};
use std::hash::Hash;
#[derive(Debug, Default)]
pub struct MidiDeviceChangeDetector {
old_connected_in_devs: NonCryptoHashSet<MidiInputDeviceId>,
old_in_config: MidiInDevsConfig,
old_connected_out_devs: NonCryptoHashSet<MidiOutputDeviceId>,
old_out_config: MidiOutDevsConfig,
}
impl MidiDeviceChangeDetector {
pub fn new() -> Self {
Default::default()
}
pub fn poll_for_midi_input_device_changes(&mut self) -> DeviceDiff<MidiInputDeviceId> {
let new_connected_devs: NonCryptoHashSet<_> = Reaper::get()
.midi_input_devices()
.filter(|d| d.is_connected())
.map(|d| d.id())
.collect();
let new_in_config = MidiInDevsConfig::from_reaper();
let diff = DeviceDiff::new(
&self.old_connected_in_devs,
&new_connected_devs,
new_in_config != self.old_in_config,
);
self.old_connected_in_devs = new_connected_devs;
self.old_in_config = new_in_config;
diff
}
pub fn poll_for_midi_output_device_changes(&mut self) -> DeviceDiff<MidiOutputDeviceId> {
let new_connected_devs: NonCryptoHashSet<_> = Reaper::get()
.midi_output_devices()
.filter(|d| d.is_connected())
.map(|d| d.id())
.collect();
let new_out_config = MidiOutDevsConfig::from_reaper();
let diff = DeviceDiff::new(
&self.old_connected_out_devs,
&new_connected_devs,
new_out_config != self.old_out_config,
);
self.old_connected_out_devs = new_connected_devs;
self.old_out_config = new_out_config;
diff
}
}
#[derive(Clone, Debug)]
pub struct DeviceDiff<T> {
pub added_devices: NonCryptoHashSet<T>,
pub removed_devices: NonCryptoHashSet<T>,
pub device_config_changed: bool,
}
impl<T: Eq + Hash + Copy> DeviceDiff<T> {
fn new(
old_devs: &NonCryptoHashSet<T>,
new_devs: &NonCryptoHashSet<T>,
device_config_changed: bool,
) -> Self {
Self {
added_devices: new_devs.difference(old_devs).copied().collect(),
removed_devices: old_devs.difference(new_devs).copied().collect(),
device_config_changed,
}
}
pub fn devices_changed(&self) -> bool {
!self.added_devices.is_empty() || !self.removed_devices.is_empty()
}
}
@@ -0,0 +1,134 @@
use crate::base::eel;
use helgoboss_learn::{
create_raw_midi_events_singleton, AbsoluteValue, FeedbackValue, MidiSourceAddress,
MidiSourceScript, MidiSourceScriptOutcome, RawMidiEvent,
};
use std::borrow::Cow;
#[derive(Debug)]
struct EelUnit {
// Declared above VM in order to be dropped before VM is dropped.
program: eel::Program,
vm: eel::Vm,
y: eel::Variable,
msg_size: eel::Variable,
address: eel::Variable,
}
#[derive(Debug)]
pub struct EelMidiSourceScript {
// Arc because EelUnit is not cloneable
eel_unit: EelUnit,
}
impl EelMidiSourceScript {
pub fn compile(eel_script: &str) -> Result<Self, String> {
if eel_script.trim().is_empty() {
return Err("script empty".to_string());
}
let vm = eel::Vm::new();
let program = vm.compile(eel_script)?;
let y = vm.register_variable("y");
let msg_size = vm.register_variable("msg_size");
let address = vm.register_variable("address");
let eel_unit = EelUnit {
program,
vm,
y,
msg_size,
address,
};
Ok(Self { eel_unit })
}
}
impl MidiSourceScript<'_> for EelMidiSourceScript {
type AdditionalInput = ();
fn execute(
&self,
input_value: FeedbackValue,
_additional_input: (),
) -> Result<MidiSourceScriptOutcome, Cow<'static, str>> {
let y_value = match input_value {
// TODO-medium Find a constant for this which is defined in EEL
FeedbackValue::Off => f64::MIN,
FeedbackValue::Numeric(v) => match v.value {
AbsoluteValue::Continuous(v) => v.get(),
AbsoluteValue::Discrete(f) => f.actual() as f64,
},
// Rest not supported for EEL
_ => f64::MIN,
};
let (slice, address) = unsafe {
self.eel_unit.y.set(y_value);
self.eel_unit.msg_size.set(0.0);
self.eel_unit.address.set(0.0);
self.eel_unit.program.execute();
let msg_size = self.eel_unit.msg_size.get().round() as i32;
if msg_size < 0 {
return Err("invalid message size".into());
};
let slice = self.eel_unit.vm.get_mem_slice(0, msg_size as u32);
let address = self.eel_unit.address.get();
(slice, address)
};
if slice.is_empty() {
return Err("empty message".into());
}
let mut array = [0; RawMidiEvent::MAX_LENGTH];
let mut i = 0u32;
for byte in slice.iter().take(RawMidiEvent::MAX_LENGTH) {
array[i as usize] = byte.round() as u8;
i += 1;
}
let raw_midi_event = RawMidiEvent::new(0, i, array);
let outcome = MidiSourceScriptOutcome {
address: if address == 0.0 {
None
} else {
Some(MidiSourceAddress::Script {
bytes: address as u64,
})
},
events: create_raw_midi_events_singleton(raw_midi_event),
};
Ok(outcome)
}
}
#[cfg(test)]
mod tests {
use super::*;
use helgoboss_learn::{FeedbackStyle, NumericFeedbackValue, UnitValue};
#[test]
fn basics() {
// Given
let text = "
address = 0x4bb0;
msg_size = 3;
0[] = 0xb0;
1[] = 0x4b;
2[] = y * 10;
";
let script = EelMidiSourceScript::compile(text).unwrap();
// When
let fb_value = NumericFeedbackValue::new(
FeedbackStyle::default(),
AbsoluteValue::Continuous(UnitValue::new(0.5)),
);
let outcome = script
.execute(FeedbackValue::Numeric(fb_value), ())
.unwrap();
// Then
assert_eq!(
outcome.address,
Some(MidiSourceAddress::Script { bytes: 0x4bb0 })
);
assert_eq!(
outcome.events,
vec![RawMidiEvent::try_from_slice(0, &[0xb0, 0x4b, 5]).unwrap()]
);
}
}
@@ -0,0 +1,312 @@
use crate::base::eel;
use helgoboss_learn::{
ControlValueKind, Transformation, TransformationInput, TransformationInstruction,
TransformationOutput,
};
use std::os::raw::c_void;
use atomic::Atomic;
use reaper_medium::reaper_str;
use std::sync::atomic::Ordering;
use std::sync::Arc;
#[derive(Default)]
pub struct AdditionalTransformationInput {
pub y_last: f64,
}
#[derive(Debug)]
struct EelUnit {
// Declared above VM in order to be dropped before VM is dropped.
program: eel::Program,
// The existence in memory and the Drop is important.
_vm: eel::Vm,
_stop: eel::Variable,
_none: eel::Variable,
x: eel::Variable,
y: eel::Variable,
y_last: eel::Variable,
y_type: eel::Variable,
last_feedback_value: eel::Variable,
timestamp: eel::Variable,
rel_time: Option<eel::Variable>,
}
#[derive(Clone, Debug)]
pub enum OutputVariable {
X,
Y,
}
pub trait Script {
fn uses_time(&self) -> bool;
fn produces_relative_values(&self) -> bool;
fn evaluate(
&self,
input: TransformationInput<AdditionalTransformationInput>,
) -> Result<TransformationOutput, &'static str>;
}
impl Script for () {
fn uses_time(&self) -> bool {
false
}
fn produces_relative_values(&self) -> bool {
false
}
fn evaluate(
&self,
input: TransformationInput<AdditionalTransformationInput>,
) -> Result<TransformationOutput, &'static str> {
let _ = input;
Err("not supported")
}
}
/// Represents a value transformation done via EEL scripting language.
#[derive(Clone, Debug)]
pub struct EelTransformation {
// Arc because EelUnit is not cloneable
eel_unit: Arc<EelUnit>,
shared_last_feedback_value: Arc<Atomic<f64>>,
output_var: OutputVariable,
wants_to_be_polled: bool,
}
impl Script for EelTransformation {
fn uses_time(&self) -> bool {
self.wants_to_be_polled()
}
fn produces_relative_values(&self) -> bool {
let input = TransformationInput::default();
let Ok(output) = self.transform(input) else {
return false;
};
// For now, we only support relative-discrete
output.produced_kind == ControlValueKind::RelativeDiscrete
}
fn evaluate(
&self,
input: TransformationInput<AdditionalTransformationInput>,
) -> Result<TransformationOutput, &'static str> {
self.transform(input)
}
}
impl EelTransformation {
pub fn compile_for_control(eel_script: &str) -> Result<EelTransformation, String> {
EelTransformation::compile(eel_script, OutputVariable::Y)
}
pub fn compile_for_feedback(eel_script: &str) -> Result<EelTransformation, String> {
EelTransformation::compile(eel_script, OutputVariable::X)
}
pub fn set_last_feedback_value(&self, value: f64) {
self.shared_last_feedback_value
.store(value, Ordering::SeqCst);
}
// Compiles the given script and creates an appropriate transformation.
fn compile(eel_script: &str, result_var: OutputVariable) -> Result<EelTransformation, String> {
if eel_script.trim().is_empty() {
return Err("script empty".to_string());
}
let mut vm = eel::Vm::new();
vm.register_single_arg_function(reaper_str!("stop"), stop);
vm.register_void_or_bool_function(reaper_str!("realearn_dbg"), realearn_dbg);
let program = vm.compile(eel_script)?;
let x = vm.register_variable("x");
let y = vm.register_variable("y");
let y_last = vm.register_variable("y_last");
let y_type = vm.register_variable("y_type");
let last_feedback_value = vm.register_variable("realearn_last_feedback_value");
let rel_time_var_name = "rel_time";
let uses_rel_time = eel_script.contains(rel_time_var_name);
let timestamp = vm.register_variable("realearn_timestamp");
let rel_time = if uses_rel_time {
Some(vm.register_variable(rel_time_var_name))
} else {
None
};
let eel_unit = EelUnit {
program,
_stop: vm.register_and_set_variable("stop", STOP),
_none: vm.register_and_set_variable("none", NONE),
_vm: vm,
x,
y,
y_last,
y_type,
last_feedback_value,
timestamp,
rel_time,
};
let transformation = EelTransformation {
eel_unit: Arc::new(eel_unit),
shared_last_feedback_value: Arc::new(Atomic::new(-1.0)),
output_var: result_var,
wants_to_be_polled: uses_rel_time,
};
Ok(transformation)
}
}
unsafe extern "C" fn stop(_: *mut c_void, amt: *mut f64) -> f64 {
CONTROL_AND_STOP_MAGIC + (*amt).clamp(0.0, 1.0)
}
unsafe extern "C" fn realearn_dbg(_: *mut c_void, amt: *mut f64) -> bool {
println!("{}", *amt);
true
}
impl Transformation for EelTransformation {
type AdditionalInput = AdditionalTransformationInput;
fn transform(
&self,
input: TransformationInput<Self::AdditionalInput>,
) -> Result<TransformationOutput, &'static str> {
let (raw_output, raw_output_type) = unsafe {
use OutputVariable::*;
let eel_unit = &*self.eel_unit;
let (input_var, output_var) = match self.output_var {
X => (eel_unit.y, eel_unit.x),
Y => (eel_unit.x, eel_unit.y),
};
input_var.set(input.event.input_value);
output_var.set(input.context.output_value);
eel_unit
.last_feedback_value
.set(self.shared_last_feedback_value.load(Ordering::SeqCst));
eel_unit.y_last.set(input.additional_input.y_last);
eel_unit.timestamp.set(input.event.timestamp.as_secs_f64());
if let Some(rel_time_var) = eel_unit.rel_time {
rel_time_var.set(input.context.rel_time.as_millis() as _);
}
eel_unit.program.execute();
(output_var.get(), self.eel_unit.y_type.get())
};
let (out_val, instruction) = if raw_output == STOP {
// Stop only
(None, Some(TransformationInstruction::Stop))
} else if raw_output == NONE {
// Neither control nor stop
(None, None)
} else if (CONTROL_AND_STOP_MAGIC..=CONTROL_AND_STOP_MAGIC + 1.0).contains(&raw_output) {
// Both control and stop
(
Some(raw_output - CONTROL_AND_STOP_MAGIC),
Some(TransformationInstruction::Stop),
)
} else {
// Control only
(Some(raw_output), None)
};
let raw_output_type = raw_output_type.round() as u8;
let produced_kind = ControlValueKind::try_from(raw_output_type).unwrap_or_default();
let output = TransformationOutput {
produced_kind,
value: out_val,
instruction,
};
Ok(output)
}
fn wants_to_be_polled(&self) -> bool {
self.wants_to_be_polled
}
}
/// Exposed as variable `stop`.
const STOP: f64 = f64::MAX;
/// Exposed as variable `none`.
const NONE: f64 = f64::MIN;
/// Not exposed but used internally when using function `stop`, e.g. `stop(0.5)`.
///
/// Since all we can do at the moment is returning one number, we define a magic number.
/// If the returned value is at a maximum 1.0 greater than that magic number, we interpret that
/// as stop instruction and extract the corresponding number!
///
/// It's good that this is encapsulated in a function. Maybe we can improve the behavior in future
/// by setting an extra output variable in the implementation of our `stop` function.
const CONTROL_AND_STOP_MAGIC: f64 = 8965019.0;
#[cfg(test)]
mod tests {
use super::*;
use bytesize::ByteSize;
use helgoboss_learn::TransformationInputEvent;
use sysinfo::ProcessRefreshKind;
#[test]
fn memory_usage() {
let mut system = sysinfo::System::new();
let current_pid = sysinfo::get_current_pid().unwrap();
system.refresh_process_specifics(current_pid, ProcessRefreshKind::new().with_memory());
let mut last_memory = 0;
let mut print_mem = move || {
system.refresh_process_specifics(current_pid, ProcessRefreshKind::new().with_memory());
let process = system.process(current_pid).unwrap();
let memory = process.memory();
let diff = memory as i64 - last_memory as i64;
let suffix = if diff.is_negative() { "-" } else { "+" };
println!(
"Memory changed by {suffix}{}. Total memory usage so far: {} bytes",
ByteSize::b(diff.unsigned_abs()),
ByteSize::b(memory)
);
last_memory = memory;
};
let mut total_count = 0;
let mut create_transformations = |count| {
total_count += count;
let transformations = create_transformations(count);
println!("Created {count} more transformation units. Total amount of units created so far: {total_count}");
print_mem();
transformations
};
let mut transformation_containers = vec![
create_transformations(1),
create_transformations(1),
create_transformations(1),
create_transformations(1),
create_transformations(1),
create_transformations(1),
create_transformations(1),
create_transformations(100),
];
println!("Now dropping from last to first...");
while transformation_containers.pop().is_some() {
println!("Dropped one set of transformations");
print_mem();
}
println!("No transformation sets left");
print_mem();
}
fn create_transformations(count: usize) -> Vec<EelTransformation> {
(0..count)
.map(|i| {
let code = format!("y = x * {i}");
let transformation = EelTransformation::compile_for_control(&code).unwrap();
let input = TransformationInput {
event: TransformationInputEvent {
input_value: 0.5,
..Default::default()
},
..Default::default()
};
transformation.transform(input).unwrap();
transformation
})
.collect()
}
}
@@ -0,0 +1,138 @@
use crate::domain::{
CompartmentKind, CompoundMappingTarget, ControlLogContext, ControlLogEntry, FeedbackLogEntry,
InternalInfoEvent, MappingId, MessageCaptureResult, PluginParamIndex, PluginParams,
ProjectionFeedbackValue, QualifiedMappingId, RawParamValue,
};
use base::hash_util::NonCryptoHashSet;
use helgoboss_learn::{AbsoluteValue, ControlValue};
use helgobox_api::persistence::MappingModification;
use std::error::Error;
use std::fmt::Debug;
/// An event which is sent to upper layers and processed there
#[derive(Debug)]
pub enum DomainEvent<'a> {
CapturedIncomingMessage(MessageCaptureEvent),
GlobalControlAndFeedbackStateChanged(GlobalControlAndFeedbackState),
UpdatedOnMappings(NonCryptoHashSet<QualifiedMappingId>),
UpdatedSingleMappingOnState(UpdatedSingleMappingOnStateEvent),
UpdatedSingleParameterValue {
index: PluginParamIndex,
value: RawParamValue,
},
UpdatedAllParameters(PluginParams),
TargetValueChanged(TargetValueChangedEvent<'a>),
Info(&'a InternalInfoEvent),
ProjectionFeedback(ProjectionFeedbackValue),
MappingMatched(MappingMatchedEvent),
HandleTargetControl(TargetControlEvent),
HandleSourceFeedback(SourceFeedbackEvent<'a>),
FullResyncRequested,
MidiDevicesChanged,
MappingEnabledChangeRequested(MappingEnabledChangeRequestedEvent),
MappingModificationRequested(MappingModificationRequestedEvent),
TimeForCelebratingSuccess,
ConditionsChanged,
}
#[derive(Copy, Clone, Eq, PartialEq, Debug, Default)]
pub struct GlobalControlAndFeedbackState {
pub control_active: bool,
pub feedback_active: bool,
}
#[derive(Clone, Debug)]
pub struct MessageCaptureEvent {
pub result: MessageCaptureResult,
pub allow_virtual_sources: bool,
pub osc_arg_index_hint: Option<u32>,
}
#[derive(Copy, Clone, Debug)]
pub struct UpdatedSingleMappingOnStateEvent {
pub id: QualifiedMappingId,
pub is_on: bool,
}
#[derive(Copy, Clone, Debug)]
pub struct MappingEnabledChangeRequestedEvent {
pub compartment: CompartmentKind,
pub mapping_id: MappingId,
pub is_enabled: bool,
}
#[derive(Clone, Debug)]
pub struct MappingModificationRequestedEvent {
pub compartment: CompartmentKind,
pub mapping_id: MappingId,
pub modification: MappingModification,
pub value: ControlValue,
}
#[derive(Copy, Clone, Debug)]
pub struct MappingMatchedEvent {
pub compartment: CompartmentKind,
pub mapping_id: MappingId,
}
impl MappingMatchedEvent {
pub fn new(compartment: CompartmentKind, mapping_id: MappingId) -> Self {
MappingMatchedEvent {
compartment,
mapping_id,
}
}
}
#[derive(Copy, Clone, Debug)]
pub struct TargetControlEvent {
pub id: QualifiedMappingId,
pub log_context: ControlLogContext,
pub log_entry: ControlLogEntry,
}
#[derive(Copy, Clone, Debug)]
pub struct SourceFeedbackEvent<'a> {
pub id: QualifiedMappingId,
pub log_entry: FeedbackLogEntry<'a>,
}
impl TargetControlEvent {
pub fn new(
id: QualifiedMappingId,
log_context: ControlLogContext,
log_entry: ControlLogEntry,
) -> Self {
Self {
id,
log_context,
log_entry,
}
}
}
#[derive(Debug)]
pub struct TargetValueChangedEvent<'a> {
pub compartment: CompartmentKind,
pub mapping_id: MappingId,
pub targets: &'a [CompoundMappingTarget],
pub new_value: AbsoluteValue,
}
pub trait DomainEventHandler: Debug {
fn handle_event_ignoring_error(&self, event: DomainEvent) {
let _ = self.handle_event(event);
}
fn handle_event(&self, event: DomainEvent) -> Result<(), Box<dyn Error>>;
fn notify_mapping_matched(&self, compartment: CompartmentKind, mapping_id: MappingId) {
self.handle_event_ignoring_error(DomainEvent::MappingMatched(MappingMatchedEvent::new(
compartment,
mapping_id,
)));
}
/// Returns `true` if another preset is being loaded.
fn auto_load_different_preset_if_necessary(&self) -> anyhow::Result<bool>;
}
@@ -0,0 +1,359 @@
use crate::domain::TrackExclusivity;
pub trait HierarchyEntryProvider {
type Entry;
fn find_entry_by_index(&self, index: u32) -> Option<Self::Entry>;
fn entry_count(&self) -> u32;
}
pub trait HierarchyEntry: PartialEq {
fn folder_depth_change(&self) -> i32;
}
pub fn handle_exclusivity<E: HierarchyEntry>(
provider: &impl HierarchyEntryProvider<Entry = E>,
exclusivity: TrackExclusivity,
current_index: Option<u32>,
current_entry: &E,
mut apply: impl FnMut(u32, &E),
) {
let current_index = match current_index {
// We consider the master track as its own folder (same as non-exclusive).
None => return,
Some(i) => i,
};
use TrackExclusivity::*;
match exclusivity {
NonExclusive => {}
ExclusiveWithinProject | ExclusiveWithinProjectOnOnly => {
for i in 0..provider.entry_count() {
let e = provider.find_entry_by_index(i).unwrap();
if &e == current_entry {
continue;
}
apply(i, &e);
}
}
ExclusiveWithinFolder | ExclusiveWithinFolderOnOnly => {
// At first look at tracks above
{
let mut delta = 0;
for i in (0..current_index).rev() {
let e = provider.find_entry_by_index(i).unwrap();
delta -= e.folder_depth_change();
if delta < 0 {
// Reached parent folder
break;
}
if delta == 0 {
// Same level
apply(i, &e);
}
}
}
// Then look at current track and tracks below.
let current_track_depth_change = current_entry.folder_depth_change();
if current_track_depth_change >= 0 {
// Current track is not the last one in the folder, so look further.
// delta will starts with 1 if the current track is a folder.
let mut delta = current_track_depth_change;
for i in (current_index + 1)..provider.entry_count() {
let e = match provider.find_entry_by_index(i) {
None => break,
Some(t) => t,
};
if delta <= 0 {
// Same level, maybe last track in folder
apply(i, &e);
}
delta += e.folder_depth_change();
if delta < 0 {
// Last track in folder
break;
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use maplit::hashset;
use std::collections::HashSet;
use {TestEntry as E, TestProvider as P};
mod exclusive_folder {
use super::*;
#[test]
fn no_folders() {
// Given
let p = P(vec![E("-"), E("-"), E("-"), E("-")]);
// When
// Then
assert_eq!(
test(&p, TrackExclusivity::ExclusiveWithinFolder, 0,),
hashset![1, 2, 3]
);
assert_eq!(
test(&p, TrackExclusivity::ExclusiveWithinFolder, 1,),
hashset![0, 2, 3]
);
assert_eq!(
test(&p, TrackExclusivity::ExclusiveWithinFolder, 2,),
hashset![0, 1, 3]
);
assert_eq!(
test(&p, TrackExclusivity::ExclusiveWithinFolder, 3,),
hashset![0, 1, 2]
);
}
#[test]
fn top_folder() {
// Given
let p = P(vec![E("/"), E("-"), E("-"), E("-")]);
// When
// Then
assert_eq!(
test(&p, TrackExclusivity::ExclusiveWithinFolder, 0,),
hashset![]
);
assert_eq!(
test(&p, TrackExclusivity::ExclusiveWithinFolder, 1,),
hashset![2, 3]
);
assert_eq!(
test(&p, TrackExclusivity::ExclusiveWithinFolder, 2,),
hashset![1, 3]
);
assert_eq!(
test(&p, TrackExclusivity::ExclusiveWithinFolder, 3,),
hashset![1, 2]
);
}
#[test]
fn bottom_folder() {
// Given
let p = P(vec![E("-"), E("-"), E("-"), E("/")]);
// When
// Then
assert_eq!(
test(&p, TrackExclusivity::ExclusiveWithinFolder, 0,),
hashset![1, 2, 3]
);
assert_eq!(
test(&p, TrackExclusivity::ExclusiveWithinFolder, 1,),
hashset![0, 2, 3]
);
assert_eq!(
test(&p, TrackExclusivity::ExclusiveWithinFolder, 2,),
hashset![0, 1, 3]
);
assert_eq!(
test(&p, TrackExclusivity::ExclusiveWithinFolder, 3,),
hashset![0, 1, 2]
);
}
#[test]
fn top_next_folder() {
// Given
let p = P(vec![E("-"), E("/"), E("-"), E("-")]);
// When
// Then
assert_eq!(
test(&p, TrackExclusivity::ExclusiveWithinFolder, 0,),
hashset![1]
);
assert_eq!(
test(&p, TrackExclusivity::ExclusiveWithinFolder, 1,),
hashset![0]
);
assert_eq!(
test(&p, TrackExclusivity::ExclusiveWithinFolder, 2,),
hashset![3]
);
assert_eq!(
test(&p, TrackExclusivity::ExclusiveWithinFolder, 3,),
hashset![2]
);
}
#[test]
fn bottom_previous_folder() {
// Given
let p = P(vec![E("-"), E("-"), E("/"), E("-")]);
// When
// Then
assert_eq!(
test(&p, TrackExclusivity::ExclusiveWithinFolder, 0,),
hashset![1, 2]
);
assert_eq!(
test(&p, TrackExclusivity::ExclusiveWithinFolder, 1,),
hashset![0, 2]
);
assert_eq!(
test(&p, TrackExclusivity::ExclusiveWithinFolder, 2,),
hashset![0, 1]
);
assert_eq!(
test(&p, TrackExclusivity::ExclusiveWithinFolder, 3,),
hashset![]
);
}
#[test]
fn small_flat_top_folder() {
// Given
let p = P(vec![E("/"), E(r#"\"#), E("-"), E("-")]);
// When
// Then
assert_eq!(
test(&p, TrackExclusivity::ExclusiveWithinFolder, 0,),
hashset![2, 3]
);
assert_eq!(
test(&p, TrackExclusivity::ExclusiveWithinFolder, 1,),
hashset![]
);
assert_eq!(
test(&p, TrackExclusivity::ExclusiveWithinFolder, 2,),
hashset![0, 3]
);
assert_eq!(
test(&p, TrackExclusivity::ExclusiveWithinFolder, 3,),
hashset![0, 2]
);
}
#[test]
fn large_flat_top_folder() {
// Given
let p = P(vec![E("/"), E("-"), E(r#"\"#), E("-")]);
// When
// Then
assert_eq!(
test(&p, TrackExclusivity::ExclusiveWithinFolder, 0,),
hashset![3]
);
assert_eq!(
test(&p, TrackExclusivity::ExclusiveWithinFolder, 1,),
hashset![2]
);
assert_eq!(
test(&p, TrackExclusivity::ExclusiveWithinFolder, 2,),
hashset![1]
);
assert_eq!(
test(&p, TrackExclusivity::ExclusiveWithinFolder, 3,),
hashset![0]
);
}
#[test]
fn large_nested_top_folder() {
// Given
let p = P(vec![E("/"), E("/"), E(r#"\\"#), E("-")]);
// When
// Then
assert_eq!(
test(&p, TrackExclusivity::ExclusiveWithinFolder, 0,),
hashset![3]
);
assert_eq!(
test(&p, TrackExclusivity::ExclusiveWithinFolder, 1,),
hashset![]
);
assert_eq!(
test(&p, TrackExclusivity::ExclusiveWithinFolder, 2,),
hashset![]
);
assert_eq!(
test(&p, TrackExclusivity::ExclusiveWithinFolder, 3,),
hashset![0]
);
}
#[test]
fn large_deeply_nested_top_folder() {
// Given
let p = P(vec![E("/"), E("/"), E("/"), E(r#"\\\"#), E("-")]);
// When
// Then
assert_eq!(
test(&p, TrackExclusivity::ExclusiveWithinFolder, 0,),
hashset![4]
);
assert_eq!(
test(&p, TrackExclusivity::ExclusiveWithinFolder, 1,),
hashset![]
);
assert_eq!(
test(&p, TrackExclusivity::ExclusiveWithinFolder, 2,),
hashset![]
);
assert_eq!(
test(&p, TrackExclusivity::ExclusiveWithinFolder, 3,),
hashset![]
);
assert_eq!(
test(&p, TrackExclusivity::ExclusiveWithinFolder, 4,),
hashset![0]
);
}
}
struct TestProvider(Vec<TestEntry>);
impl HierarchyEntryProvider for TestProvider {
type Entry = TestEntry;
fn find_entry_by_index(&self, index: u32) -> Option<Self::Entry> {
self.0.get(index as usize).copied()
}
fn entry_count(&self) -> u32 {
self.0.len() as _
}
}
#[derive(Copy, Clone, PartialEq)]
struct TestEntry(&'static str);
impl HierarchyEntry for TestEntry {
fn folder_depth_change(&self) -> i32 {
match self.0 {
"-" => 0,
"/" => 1,
r#"\"# => -1,
r#"\\"# => -2,
r#"\\\"# => -3,
_ => panic!("unknown entry symbol"),
}
}
}
fn test(
provider: &TestProvider,
exclusivity: TrackExclusivity,
current_index: u32,
) -> HashSet<u32> {
let mut affected_indexes = HashSet::new();
handle_exclusivity(
provider,
exclusivity,
Some(current_index),
&provider.find_entry_by_index(current_index).unwrap(),
|i, _| {
affected_indexes.insert(i);
},
);
affected_indexes
}
}
@@ -0,0 +1,134 @@
use crate::domain::{
FeedbackOutput, FinalRealFeedbackValue, FinalSourceFeedbackValue, MidiDestination,
PreliminaryRealFeedbackValue, PreliminarySourceFeedbackValue, RealearnSourceState,
};
use base::hash_util::NonCryptoHashSet;
use helgoboss_learn::devices::x_touch::XTouchMackieLcdState;
use helgoboss_learn::{
DisplaySpecAddress, MackieLcdScope, MidiSourceValue, RawFeedbackAddressInfo, RawMidiEvent,
XTouchMackieLcdColorRequest,
};
/// Responsible for collecting non-final feedback values and aggregating them into final ones.
pub struct FeedbackCollector<'a> {
x_touch_mackie_lcd_feedback_collector: Option<XTouchMackieLcdFeedbackCollector<'a>>,
}
struct XTouchMackieLcdFeedbackCollector<'a> {
state: &'a mut XTouchMackieLcdState,
changed_x_touch_mackie_lcd_extenders: NonCryptoHashSet<u8>,
}
impl<'a> FeedbackCollector<'a> {
pub fn new(
global_source_state: &'a mut RealearnSourceState,
feedback_output: Option<FeedbackOutput>,
) -> Self {
let x_touch_mackie_lcd_state = match feedback_output {
Some(FeedbackOutput::Midi(MidiDestination::Device(dev_id))) => {
Some(global_source_state.get_x_touch_mackie_lcd_state_mut(dev_id))
}
// No or no direct MIDI device output. Then we can ignore this because
// the X-Touch!
_ => None,
};
Self {
x_touch_mackie_lcd_feedback_collector: x_touch_mackie_lcd_state.map(|state| {
XTouchMackieLcdFeedbackCollector {
state,
changed_x_touch_mackie_lcd_extenders: Default::default(),
}
}),
}
}
/// Spits the given feedback value immediately out again if it's already final or only has a
/// projection part, but collects it if it's non-final.
pub fn process(
&mut self,
preliminary_feedback_value: PreliminaryRealFeedbackValue,
) -> Option<FinalRealFeedbackValue> {
match preliminary_feedback_value.source {
None => {
// Has projection part only.
FinalRealFeedbackValue::new(preliminary_feedback_value.projection, None)
}
Some(preliminary_source_feedback_value) => match preliminary_source_feedback_value {
PreliminarySourceFeedbackValue::Midi(v) => {
if let Some(req) = v.x_touch_mackie_lcd_color_request {
self.process_x_touch_mackie_lcd_color_request(req);
}
FinalRealFeedbackValue::new(
preliminary_feedback_value.projection,
Some(FinalSourceFeedbackValue::Midi(v.final_value)),
)
}
// Is final OSC value already.
PreliminarySourceFeedbackValue::Osc(v) => FinalRealFeedbackValue::new(
preliminary_feedback_value.projection,
Some(FinalSourceFeedbackValue::Osc(v)),
),
// Is final REAPER source value already.
PreliminarySourceFeedbackValue::Reaper(v) => FinalRealFeedbackValue::new(
preliminary_feedback_value.projection,
Some(FinalSourceFeedbackValue::Reaper(v)),
),
// Is final StreamDeck source value already.
PreliminarySourceFeedbackValue::StreamDeck(v) => FinalRealFeedbackValue::new(
preliminary_feedback_value.projection,
Some(FinalSourceFeedbackValue::StreamDeck(v)),
),
},
}
}
/// Takes the collected and aggregated material and produces the final feedback values.
pub fn generate_final_feedback_values(
self,
) -> impl Iterator<Item = FinalRealFeedbackValue> + 'a {
self.x_touch_mackie_lcd_feedback_collector
.into_iter()
.flat_map(|x_touch_collector| {
x_touch_collector
.changed_x_touch_mackie_lcd_extenders
.into_iter()
.filter_map(|extender_index| {
let sysex = x_touch_collector.state.sysex(extender_index);
let midi_event = RawMidiEvent::try_from_iter(0, sysex).ok()?;
let feedback_address = RawFeedbackAddressInfo::Display {
spec: DisplaySpecAddress::XTouchMackieLcdColors { extender_index },
};
let source_feedback_value = FinalSourceFeedbackValue::Midi(
MidiSourceValue::single_raw(Some(feedback_address), midi_event),
);
FinalRealFeedbackValue::new(None, Some(source_feedback_value))
})
})
}
fn process_x_touch_mackie_lcd_color_request(&mut self, req: XTouchMackieLcdColorRequest) {
let collector = match &mut self.x_touch_mackie_lcd_feedback_collector {
None => return,
Some(c) => c,
};
let channels = match req.channel {
None => 0..MackieLcdScope::CHANNEL_COUNT,
Some(ch) => ch..ch + 1,
};
let mut at_least_one_color_change = false;
for ch in channels {
let changed =
collector
.state
.notify_color_requested(req.extender_index, ch, req.color_index);
if changed {
at_least_one_color_change = true;
}
}
if at_least_one_color_change {
collector
.changed_x_touch_mackie_lcd_extenders
.insert(req.extender_index);
}
}
}
@@ -0,0 +1,24 @@
use crate::domain::{AdditionalLuaMidiSourceScriptInput, EelMidiSourceScript, LuaMidiSourceScript};
use helgoboss_learn::{FeedbackValue, MidiSourceScript, MidiSourceScriptOutcome};
use std::borrow::Cow;
#[derive(Debug)]
pub enum FlexibleMidiSourceScript<'lua> {
Eel(EelMidiSourceScript),
Lua(LuaMidiSourceScript<'lua>),
}
impl<'a, 'lua: 'a> MidiSourceScript<'a> for FlexibleMidiSourceScript<'lua> {
type AdditionalInput = AdditionalLuaMidiSourceScriptInput<'a>;
fn execute(
&self,
input_value: FeedbackValue,
additional_input: Self::AdditionalInput,
) -> Result<MidiSourceScriptOutcome, Cow<'static, str>> {
match self {
FlexibleMidiSourceScript::Eel(s) => s.execute(input_value, ()),
FlexibleMidiSourceScript::Lua(s) => s.execute(input_value, additional_input),
}
}
}
@@ -0,0 +1,55 @@
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
use atomic::Atomic;
use reaper_medium::Hz;
use static_assertions::const_assert;
use crate::domain::AudioBlockProps;
pub static GLOBAL_AUDIO_STATE: GlobalAudioState = GlobalAudioState::new();
#[derive(Debug)]
pub struct GlobalAudioState {
block_count: AtomicU64,
block_size: AtomicU32,
sample_rate: Atomic<f64>,
}
impl Default for GlobalAudioState {
fn default() -> Self {
Self::new()
}
}
impl GlobalAudioState {
pub const fn new() -> Self {
const_assert!(Atomic::<f64>::is_lock_free());
Self {
block_count: AtomicU64::new(0),
block_size: AtomicU32::new(0),
sample_rate: Atomic::new(1.0),
}
}
/// Returns previous block count
pub fn advance(&self, block_props: AudioBlockProps) -> u64 {
let prev_block_count = self.block_count.fetch_add(1, Ordering::Relaxed);
self.block_size
.store(block_props.block_length as u32, Ordering::Relaxed);
self.sample_rate
.store(block_props.frame_rate.get(), Ordering::Relaxed);
prev_block_count
}
pub fn load_block_count(&self) -> u64 {
self.block_count.load(Ordering::Relaxed)
}
pub fn load_block_size(&self) -> u32 {
self.block_size.load(Ordering::Relaxed)
}
pub fn load_sample_rate(&self) -> Hz {
Hz::new_panic(self.sample_rate.load(Ordering::Relaxed))
}
}
@@ -0,0 +1,80 @@
use derive_more::Display;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::str::FromStr;
use uuid::Uuid;
/// Internal technical group identifier, not persistent.
///
/// Goals: Quick lookup, guaranteed uniqueness, cheap copy
#[derive(
Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Serialize, Deserialize, Default,
)]
#[serde(transparent)]
pub struct GroupId {
uuid: Uuid,
}
impl GroupId {
pub fn is_default(&self) -> bool {
self.uuid.is_nil()
}
pub fn random() -> GroupId {
GroupId {
uuid: Uuid::new_v4(),
}
}
}
impl fmt::Display for GroupId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.uuid)
}
}
impl FromStr for GroupId {
type Err = &'static str;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let uuid = Uuid::from_str(s).map_err(|_| "group ID must be a valid UUID")?;
Ok(Self { uuid })
}
}
/// A potentially user-defined group identifier, persistent
///
/// Goals: For external references (e.g. from API or in projection)
#[derive(
Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default, Display, Serialize, Deserialize,
)]
#[serde(transparent)]
pub struct GroupKey(String);
impl GroupKey {
pub fn random() -> Self {
Self(nanoid::nanoid!())
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
impl AsRef<str> for GroupKey {
fn as_ref(&self) -> &str {
&self.0
}
}
impl From<String> for GroupKey {
fn from(v: String) -> Self {
Self(v)
}
}
impl From<GroupKey> for String {
fn from(v: GroupKey) -> Self {
v.0
}
}
@@ -0,0 +1,25 @@
use std::fmt::{Display, Formatter};
pub fn parse_hex_string(value: &str) -> Result<Vec<u8>, hex::FromHexError> {
let without_spaces = value.replace(' ', "");
hex::decode(without_spaces)
}
/// Formats the given slice of bytes as hex numbers separated by spaces.
pub fn format_as_pretty_hex(bytes: &[u8]) -> String {
DisplayAsPrettyHex(bytes).to_string()
}
pub struct DisplayAsPrettyHex<'a>(pub &'a [u8]);
impl Display for DisplayAsPrettyHex<'_> {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
for (i, b) in self.0.iter().enumerate() {
if i > 0 {
f.write_str(" ")?;
}
write!(f, "{:02X?}", *b)?;
}
Ok(())
}
}
@@ -0,0 +1,488 @@
use crate::domain::{AnyThreadBackboneState, Backbone, ProcessorContext, RealTimeInstance, UnitId};
#[allow(unused_imports)]
use anyhow::Context;
use base::hash_util::NonCryptoHashMap;
use base::{NamedChannelSender, SenderToNormalThread, SenderToRealTimeThread};
use helgobox_api::persistence::PotFilterKind;
use pot::{
CurrentPreset, OptFilter, PotFavorites, PotFilterExcludes, PotIntegration, PotUnit, PresetId,
SharedRuntimePotUnit,
};
use reaper_high::{ChangeEvent, Fx};
use std::cell::{Ref, RefCell, RefMut};
use std::fmt;
use std::num::ParseIntError;
use std::rc::{Rc, Weak};
use std::str::FromStr;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::RwLock;
pub type SharedInstance = Rc<RefCell<Instance>>;
pub type WeakInstance = Weak<RefCell<Instance>>;
#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Debug, Hash)]
pub struct InstanceId(u32);
#[derive(Debug)]
pub struct Instance {
id: InstanceId,
processor_context: ProcessorContext,
feedback_event_sender: SenderToNormalThread<QualifiedInstanceEvent>,
main_unit_id: UnitId,
#[allow(unused)]
handler: Box<dyn InstanceHandler>,
/// Saves the current state for Pot preset navigation.
///
/// Persistent.
pot_unit: PotUnit,
pub audio_hook_task_sender: base::SenderToRealTimeThread<crate::domain::NormalAudioHookTask>,
pub real_time_instance_task_sender:
base::SenderToRealTimeThread<crate::domain::RealTimeInstanceTask>,
custom_data: NonCryptoHashMap<String, serde_json::Value>,
#[cfg(feature = "playtime")]
pub playtime: PlaytimeInstance,
}
#[cfg(feature = "playtime")]
#[derive(Debug)]
pub struct PlaytimeInstance {
clip_matrix: Option<playtime_clip_engine::base::Matrix>,
pub clip_matrix_event_sender: SenderToNormalThread<QualifiedClipMatrixEvent>,
}
pub trait InstanceHandler: fmt::Debug {
#[cfg(feature = "playtime")]
fn clip_matrix_changed(
&self,
instance_id: InstanceId,
matrix: &playtime_clip_engine::base::Matrix,
events: &[playtime_clip_engine::base::ClipMatrixEvent],
is_poll: bool,
);
#[cfg(feature = "playtime")]
fn process_control_surface_change_event_for_clip_engine(
&self,
instance_id: InstanceId,
matrix: &playtime_clip_engine::base::Matrix,
events: &[reaper_high::ChangeEvent],
);
}
impl Drop for Instance {
fn drop(&mut self) {
if Backbone::is_loaded() {
Backbone::get().unregister_instance(&self.id);
}
}
}
#[derive(Debug)]
pub struct QualifiedInstanceEvent {
pub instance_id: InstanceId,
pub event: InstanceStateChanged,
}
#[cfg(feature = "playtime")]
#[derive(Debug)]
pub struct QualifiedClipMatrixEvent {
pub instance_id: InstanceId,
pub event: playtime_clip_engine::base::ClipMatrixEvent,
}
impl fmt::Display for InstanceId {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
self.0.fmt(f)
}
}
impl FromStr for InstanceId {
type Err = ParseIntError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self(s.parse()?))
}
}
impl InstanceId {
pub fn next() -> Self {
static COUNTER: AtomicU32 = AtomicU32::new(0);
Self(COUNTER.fetch_add(1, Ordering::SeqCst))
}
}
impl From<u32> for InstanceId {
fn from(value: u32) -> Self {
Self(value)
}
}
impl From<InstanceId> for u32 {
fn from(value: InstanceId) -> Self {
value.0
}
}
const REAL_TIME_INSTANCE_TASK_QUEUE_SIZE: usize = 200;
impl Instance {
pub fn new(
id: InstanceId,
main_unit_id: UnitId,
processor_context: ProcessorContext,
feedback_event_sender: SenderToNormalThread<QualifiedInstanceEvent>,
handler: Box<dyn InstanceHandler>,
#[cfg(feature = "playtime")] clip_matrix_event_sender: SenderToNormalThread<
QualifiedClipMatrixEvent,
>,
audio_hook_task_sender: base::SenderToRealTimeThread<crate::domain::NormalAudioHookTask>,
) -> (Self, RealTimeInstance) {
let (real_time_instance_task_sender, real_time_instance_task_receiver) =
SenderToRealTimeThread::new_channel(
"real-time instance tasks",
REAL_TIME_INSTANCE_TASK_QUEUE_SIZE,
);
let rt_instance = RealTimeInstance::new(real_time_instance_task_receiver);
let instance = Self {
id,
main_unit_id,
feedback_event_sender,
handler,
processor_context,
pot_unit: Default::default(),
#[cfg(feature = "playtime")]
playtime: PlaytimeInstance {
clip_matrix: None,
clip_matrix_event_sender,
},
audio_hook_task_sender,
real_time_instance_task_sender,
custom_data: Default::default(),
};
(instance, rt_instance)
}
pub fn id(&self) -> InstanceId {
self.id
}
pub fn main_unit_id(&self) -> UnitId {
self.main_unit_id
}
pub fn custom_data(&self) -> &NonCryptoHashMap<String, serde_json::Value> {
&self.custom_data
}
pub fn set_custom_data(&mut self, data: NonCryptoHashMap<String, serde_json::Value>) {
self.custom_data = data;
}
pub fn update_custom_data_key(&mut self, key: String, value: serde_json::Value) {
self.custom_data.insert(key, value);
}
/// Returns the runtime pot unit associated with this instance.
///
/// If the pot unit isn't loaded yet and loading has not been attempted yet, loads it.
///
/// Returns an error if the necessary pot database is not available.
pub fn pot_unit(&mut self) -> Result<SharedRuntimePotUnit, &'static str> {
let integration = RealearnPotIntegration::new(
self.id,
self.processor_context.containing_fx().clone(),
self.feedback_event_sender.clone(),
);
self.pot_unit.loaded(Box::new(integration))
}
/// Restores a pot unit state from persistent data.
///
/// This doesn't load the pot unit yet. If the ReaLearn instance never accesses the pot unit,
/// it simply remains unloaded and its persistent state is kept. The persistent state is also
/// kept if loading of the pot unit fails (e.g. if the necessary pot database is not available
/// on the user's computer).
pub fn restore_pot_unit(&mut self, state: pot::PersistentState) {
self.pot_unit = PotUnit::unloaded(state);
}
/// Returns a pot unit state suitable to be saved by the persistence logic.
pub fn save_pot_unit(&self) -> pot::PersistentState {
self.pot_unit.persistent_state()
}
pub fn process_control_surface_change_events(&mut self, events: &[ChangeEvent]) {
#[cfg(not(feature = "playtime"))]
{
let _ = events;
}
#[cfg(feature = "playtime")]
{
if events.is_empty() {
return;
}
if let Some(matrix) = self.playtime.clip_matrix.as_mut() {
// Let matrix react to track changes etc.
matrix.process_reaper_change_events(events);
// Process for GUI
self.handler
.process_control_surface_change_event_for_clip_engine(self.id, matrix, events);
}
}
}
pub fn notify_mappings_in_unit_changed(&self, unit_id: UnitId) {
#[cfg(not(feature = "playtime"))]
{
let _ = unit_id;
}
#[cfg(feature = "playtime")]
if unit_id == self.main_unit_id {
if let Some(matrix) = self.clip_matrix() {
matrix.notify_simple_mappings_changed();
}
}
}
pub fn notify_learning_target_in_unit_changed(&self, unit_id: UnitId) {
#[cfg(not(feature = "playtime"))]
{
let _ = unit_id;
}
#[cfg(feature = "playtime")]
if unit_id == self.main_unit_id {
if let Some(matrix) = self.clip_matrix() {
matrix.notify_learning_target_changed();
}
}
}
pub fn has_clip_matrix(&self) -> bool {
#[cfg(feature = "playtime")]
{
self.playtime.clip_matrix.is_some()
}
#[cfg(not(feature = "playtime"))]
{
false
}
}
}
#[cfg(feature = "playtime")]
mod playtime_impl {
use crate::domain::instance::NO_CLIP_MATRIX_SET;
use crate::domain::{
err_if_reaper_version_too_low_for_playtime, Instance, QualifiedClipMatrixEvent,
};
use anyhow::Context;
use base::NamedChannelSender;
impl Instance {
/// Polls the Playtime matrix of this ReaLearn instance.
pub fn poll_owned_clip_matrix(
&mut self,
) -> Vec<playtime_clip_engine::base::ClipMatrixEvent> {
let Some(matrix) = self.playtime.clip_matrix.as_mut() else {
return vec![];
};
let events = matrix.poll();
self.handler
.clip_matrix_changed(self.id, matrix, &events, true);
events
}
pub fn process_non_polled_clip_matrix_event(
&self,
event: &crate::domain::QualifiedClipMatrixEvent,
) {
if event.instance_id != self.id {
return;
}
let Some(matrix) = self.clip_matrix() else {
return;
};
self.handler.clip_matrix_changed(
self.id,
matrix,
std::slice::from_ref(&event.event),
false,
);
}
pub fn get_playtime_matrix(&self) -> anyhow::Result<&playtime_clip_engine::base::Matrix> {
self.playtime
.clip_matrix
.as_ref()
.context(NO_CLIP_MATRIX_SET)
}
pub fn get_playtime_matrix_mut(
&mut self,
) -> anyhow::Result<&mut playtime_clip_engine::base::Matrix> {
self.playtime
.clip_matrix
.as_mut()
.context(NO_CLIP_MATRIX_SET)
}
pub fn clip_matrix(&self) -> Option<&playtime_clip_engine::base::Matrix> {
self.playtime.clip_matrix.as_ref()
}
pub fn clip_matrix_mut(&mut self) -> Option<&mut playtime_clip_engine::base::Matrix> {
self.playtime.clip_matrix.as_mut()
}
/// Returns `Ok(true)` if it installed a Playtime matrix and `Ok(false)` if one was installed already.
///
/// # Errors
///
/// Returns an error if the Playtime matrix can't be created, e.g. when on the monitoring FX chain.
pub(crate) fn create_and_install_clip_matrix_if_necessary(
&mut self,
create_handler: impl FnOnce(
&Instance,
)
-> Box<dyn playtime_clip_engine::base::ClipMatrixHandler>,
) -> anyhow::Result<bool> {
if self.playtime.clip_matrix.is_some() {
return Ok(false);
}
err_if_reaper_version_too_low_for_playtime()?;
let track = self.processor_context.track()
.context("Sorry, Playtime is not intended to be used from the monitoring FX chain! If you have a really good use case for that, please write to info@helgoboss.org and we will see what we can do.")?;
let matrix =
playtime_clip_engine::base::Matrix::new(create_handler(self), track.clone());
self.update_real_time_clip_matrix(Some(matrix.real_time_matrix()));
self.set_clip_matrix(Some(matrix));
self.playtime
.clip_matrix_event_sender
.send_complaining(QualifiedClipMatrixEvent {
instance_id: self.id,
event: playtime_clip_engine::base::ClipMatrixEvent::EverythingChanged,
});
Ok(true)
}
pub fn set_clip_matrix(&mut self, matrix: Option<playtime_clip_engine::base::Matrix>) {
if self.playtime.clip_matrix.is_some() {
tracing::debug!("Shutdown existing Playtime matrix");
self.update_real_time_clip_matrix(None);
}
self.playtime.clip_matrix = matrix;
}
pub(super) fn update_real_time_clip_matrix(
&self,
real_time_matrix: Option<playtime_clip_engine::rt::WeakRtMatrix>,
) {
let rt_task = crate::domain::RealTimeInstanceTask::SetClipMatrix {
matrix: real_time_matrix,
};
self.real_time_instance_task_sender
.send_complaining(rt_task);
}
}
}
struct RealearnPotIntegration {
instance_id: InstanceId,
containing_fx: Fx,
sender: SenderToNormalThread<QualifiedInstanceEvent>,
}
impl RealearnPotIntegration {
fn new(
instance_id: InstanceId,
containing_fx: Fx,
sender: SenderToNormalThread<QualifiedInstanceEvent>,
) -> Self {
Self {
instance_id,
containing_fx,
sender,
}
}
fn emit(&self, event: InstanceStateChanged) {
self.sender.send_complaining(QualifiedInstanceEvent {
instance_id: self.instance_id,
event,
})
}
}
impl PotIntegration for RealearnPotIntegration {
fn favorites(&self) -> &RwLock<PotFavorites> {
&AnyThreadBackboneState::get().pot_favorites
}
fn set_current_fx_preset(&self, fx: Fx, preset: CurrentPreset) {
Backbone::target_state()
.borrow_mut()
.set_current_fx_preset(fx, preset);
self.emit(InstanceStateChanged::PotStateChanged(
PotStateChangedEvent::PresetLoaded,
));
}
fn exclude_list(&self) -> Ref<PotFilterExcludes> {
Backbone::get().pot_filter_exclude_list()
}
fn exclude_list_mut(&self) -> RefMut<PotFilterExcludes> {
Backbone::get().pot_filter_exclude_list_mut()
}
fn notify_preset_changed(&self, id: Option<PresetId>) {
self.emit(InstanceStateChanged::PotStateChanged(
PotStateChangedEvent::PresetChanged { id },
));
}
fn notify_filter_changed(&self, kind: PotFilterKind, filter: OptFilter) {
self.emit(InstanceStateChanged::PotStateChanged(
PotStateChangedEvent::FilterItemChanged { kind, filter },
));
}
fn notify_indexes_rebuilt(&self) {
self.emit(InstanceStateChanged::PotStateChanged(
PotStateChangedEvent::IndexesRebuilt,
));
}
fn protected_fx(&self) -> &Fx {
&self.containing_fx
}
}
#[derive(Clone, Debug)]
#[allow(clippy::enum_variant_names)]
pub enum InstanceStateChanged {
PotStateChanged(PotStateChangedEvent),
}
#[derive(Clone, Debug)]
pub enum PotStateChangedEvent {
FilterItemChanged {
kind: PotFilterKind,
filter: OptFilter,
},
PresetChanged {
id: Option<PresetId>,
},
IndexesRebuilt,
PresetLoaded,
}
#[cfg(feature = "playtime")]
const NO_CLIP_MATRIX_SET: &str = "no Playtime matrix set for this instance";
#[cfg(feature = "playtime")]
pub fn err_if_reaper_version_too_low_for_playtime() -> anyhow::Result<()> {
const MIN_REAPER_VERSION: &str = "7";
if reaper_high::Reaper::get().version().revision() < MIN_REAPER_VERSION {
anyhow::bail!("Please update REAPER to version {MIN_REAPER_VERSION} to access Playtime!");
}
Ok(())
}
@@ -0,0 +1,4 @@
#[derive(Debug)]
pub enum InternalInfoEvent {
UndesiredAllocationCountChanged,
}
@@ -0,0 +1,92 @@
use crate::domain::{MidiControlInput, MidiDestination, OscDeviceId};
use reaper_medium::{MidiInputDeviceId, MidiOutputDeviceId};
#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)]
pub enum ControlInput {
Midi(MidiControlInput),
Osc(OscDeviceId),
}
impl ControlInput {
pub fn from_device_input(input: DeviceControlInput) -> Self {
match input {
DeviceControlInput::Midi(id) => Self::Midi(MidiControlInput::Device(id)),
DeviceControlInput::Osc(id) => Self::Osc(id),
}
}
pub fn midi_control_input(self) -> Option<MidiControlInput> {
if let ControlInput::Midi(i) = self {
Some(i)
} else {
None
}
}
pub fn device_input(self) -> Option<DeviceControlInput> {
use ControlInput::*;
match self {
Midi(MidiControlInput::Device(id)) => Some(DeviceControlInput::Midi(id)),
Osc(id) => Some(DeviceControlInput::Osc(id)),
_ => None,
}
}
pub fn is_midi_device(self) -> bool {
matches!(self, ControlInput::Midi(MidiControlInput::Device(_)))
}
pub fn is_midi_fx_input(self) -> bool {
matches!(self, ControlInput::Midi(MidiControlInput::FxInput))
}
}
impl Default for ControlInput {
fn default() -> Self {
Self::Midi(MidiControlInput::FxInput)
}
}
#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)]
pub enum DeviceControlInput {
Midi(MidiInputDeviceId),
Osc(OscDeviceId),
}
#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)]
pub enum FeedbackOutput {
Midi(MidiDestination),
Osc(OscDeviceId),
}
impl FeedbackOutput {
pub fn from_device_output(output: DeviceFeedbackOutput) -> Self {
match output {
DeviceFeedbackOutput::Midi(id) => Self::Midi(MidiDestination::Device(id)),
DeviceFeedbackOutput::Osc(id) => Self::Osc(id),
}
}
pub fn midi_destination(&self) -> Option<MidiDestination> {
if let Self::Midi(dest) = self {
Some(*dest)
} else {
None
}
}
pub fn device_output(self) -> Option<DeviceFeedbackOutput> {
use FeedbackOutput::*;
match self {
Midi(MidiDestination::Device(id)) => Some(DeviceFeedbackOutput::Midi(id)),
Osc(id) => Some(DeviceFeedbackOutput::Osc(id)),
_ => None,
}
}
}
#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)]
pub enum DeviceFeedbackOutput {
Midi(MidiOutputDeviceId),
Osc(OscDeviceId),
}
@@ -0,0 +1,329 @@
use crate::domain::ControlOutcome;
use enumflags2::BitFlags;
use helgoboss_learn::{ControlValue, UnitValue};
use reaper_high::{AcceleratorKey, Reaper};
use reaper_medium::{
virt_keys, Accel, AccelMsgKind, AcceleratorBehavior, AcceleratorKeyCode, ReaperString, VirtKey,
};
use std::borrow::Cow;
use std::fmt::{Display, Formatter};
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
pub struct KeySource {
currently_pressed: bool,
stroke: Keystroke,
}
impl KeySource {
pub fn new(stroke: Keystroke) -> Self {
Self {
currently_pressed: false,
stroke,
}
}
pub fn stroke(&self) -> Keystroke {
self.stroke
}
pub fn control(&mut self, msg: KeyMessage) -> Option<ControlOutcome<ControlValue>> {
if !(msg.stroke() == self.stroke) {
// If strokes don't match, we can return early. All tests below assume that the stroke matches.
return None;
}
if !msg.interaction_kind().is_press_or_release() {
// On Windows, there's not just press and release but also something like "key is being
// hold", which fires continuously. We neither want to react to it (because we have our
// own fire modes) nor simply forward it to REAPER (because it would dig a hole
// into our "Filter matched events" mechanism). We let this source "consume" the message
// instead.
// Oh yes, and there's "Char". If in a text field, Windows (and maybe also other OS?)
// sends for each character key press an additional "Char" interaction. It should have
// been normalized in the accelerator and match the keystroke of the key-down event.
// As a result, we consume it as well.
return Some(ControlOutcome::Consumed);
}
let is_press = msg.interaction_kind().is_press();
if is_press && self.currently_pressed {
// We don't want OS-triggered repeated key firing (macOS). We have our own fire modes.
return Some(ControlOutcome::Consumed);
}
let control_value = self.get_control_value(msg)?;
self.currently_pressed = is_press;
Some(ControlOutcome::Matched(control_value))
}
/// Non-mutating! Used for checks.
pub fn reacts_to_message_with(&self, msg: KeyMessage) -> Option<ControlValue> {
if !msg.interaction_kind().is_press_or_release() {
return None;
}
self.get_control_value(msg)
}
/// Assumes that relevance has been checked already.
fn get_control_value(&self, msg: KeyMessage) -> Option<ControlValue> {
if msg.stroke != self.stroke {
return None;
}
let value = if msg.interaction_kind().is_press() {
UnitValue::MAX
} else {
UnitValue::MIN
};
Some(ControlValue::AbsoluteContinuous(value))
}
}
impl Display for KeySource {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
self.stroke.fmt(f)
}
}
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
pub struct KeyMessage {
kind: AccelMsgKind,
stroke: Keystroke,
}
impl KeyMessage {
pub fn new(kind: AccelMsgKind, stroke: Keystroke) -> Self {
Self { kind, stroke }
}
pub fn interaction_kind(&self) -> KeyInteractionKind {
use AccelMsgKind::*;
match self.kind {
KeyDown | SysKeyDown => KeyInteractionKind::Press,
KeyUp | SysKeyUp => KeyInteractionKind::Release,
_ => KeyInteractionKind::Other,
}
}
pub fn stroke(&self) -> Keystroke {
self.stroke
}
}
impl Display for KeyMessage {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{} {}", self.interaction_kind(), self.stroke)
}
}
#[derive(Copy, Clone, Eq, PartialEq, Debug, derive_more::Display)]
pub enum KeyInteractionKind {
Press,
Release,
Other,
}
impl KeyInteractionKind {
pub fn is_press(&self) -> bool {
matches!(self, Self::Press)
}
/// Checks if the kind is relevant (only key-down and key-up).
pub fn is_press_or_release(&self) -> bool {
matches!(self, Self::Press | Self::Release)
}
}
#[derive(Copy, Clone, Eq, PartialEq, Debug, serde::Serialize, serde::Deserialize)]
pub struct Keystroke {
modifiers: BitFlags<AcceleratorBehavior>,
key: AcceleratorKeyCode,
}
#[derive(Copy, Clone, Eq, PartialEq, Debug, derive_more::Display)]
pub enum KeyStrokePortability {
NonPortable(PortabilityIssue),
Portable,
}
#[derive(Copy, Clone, Eq, PartialEq, Debug, derive_more::Display)]
pub enum PortabilityIssue {
NotNormalized,
OperatingSystemRelated,
KeyboardLayoutRelated,
Other,
}
impl Keystroke {
pub fn new(behavior: BitFlags<AcceleratorBehavior>, key: AcceleratorKeyCode) -> Self {
Self {
modifiers: behavior,
key,
}
}
/// This normalizes the given behavior/key combination so it works cross-platform.
///
/// When REAPER notifies us about incoming key events, the accelerator behavior and key codes
/// look slightly different depending on the operating system:
///
/// - On all operating systems, if we have a key combination, we receive each key event
/// separately, even the modifier keys. Good!
/// - If we have a key combination (modifier key + normal key), Windows doesn't mention the
/// modifier keys in the accelerator behavior, but macOS and Linux do. We prefer the Windows
/// way because it makes more sense in this context. We receive modifier key-ups and key-downs
/// separately anyway.
/// - On Windows, umlauts are delivered as virtual keys, on macOS and Linux as character codes.
/// We prefer the macOS and Linux way.
/// - On Windows, "normal" special characters such as # and + are delivered as virtual keys.
/// On macOS and Linux, they are delivered as character codes.
/// - On Windows, "abnormal" special characters such as ^ or ` are delivered as virtual keys.
/// On macOS, they are also delivered as virtual keys but with a different code.
/// On Linux, they are delivered as character code.
/// We don't like any. Mark them as non-portable!
#[allow(clippy::if_same_then_else)]
pub fn normalized(&self) -> Self {
use AcceleratorBehavior::*;
let mut modifiers = self.modifiers;
let key = self.key;
// Remove modifier info (makes a difference on macOS and Linux only).
modifiers.remove(Shift | Control | Alt);
// Do some Windows-specific conversions.
#[cfg(windows)]
{
if modifiers.contains(VirtKey) {
// Key is a virtual key.
// On Windows, we need to convert virtual keys for umlauts or special characters to
// character codes so we match the behavior of macOS and Linux.
let character_code = unsafe {
winapi::um::winuser::MapVirtualKeyW(
key.get() as u32,
winapi::um::winuser::MAPVK_VK_TO_CHAR,
)
};
if character_code == 0 {
// Couldn't find corresponding character code.
Self::new(modifiers, key)
} else if character_code == key.get() as u32 {
// Character code is equal to virtual key code. In this case, macOS and Linux
// would also use the virtual key code (I hope), so we keep it.
Self::new(modifiers, key)
} else {
// We have a completely different character code. Use this one because
// macOS and Linux would also prefer the character code.
modifiers.remove(VirtKey);
Self::new(modifiers, AcceleratorKeyCode::new(character_code as u16))
}
} else {
// Key is a character code. Use as is.
Self::new(modifiers, key)
}
}
// On Linux and macOS, this is not necessary.
#[cfg(not(windows))]
{
Self::new(modifiers, key)
}
}
pub fn modifiers(&self) -> BitFlags<AcceleratorBehavior> {
self.modifiers
}
pub fn key_code(&self) -> AcceleratorKeyCode {
self.key
}
/// Returns information about portability of this keystroke across operating systems, keyboards,
/// layouts, if known.
pub fn portability(&self) -> Option<KeyStrokePortability> {
use KeyStrokePortability::*;
use PortabilityIssue::*;
let normalized = self.normalized();
if *self != normalized {
return Some(KeyStrokePortability::NonPortable(
PortabilityIssue::NotNormalized,
));
}
match self.accelerator_key() {
AcceleratorKey::Character(ch) => {
match ch {
// Consider non-ASCII characters generally as non-portable.
x if x > 0x7f => Some(NonPortable(KeyboardLayoutRelated)),
a => {
let a = a as u8;
match a {
// These ones are at least on the numpad. Numpad is layout-agnostic.
b'+' | b'-' | b'*' | b'/' => Some(Portable),
// These have special behavior on some keyboard layouts.
b'`' | b'^' => Some(NonPortable(KeyboardLayoutRelated)),
// Since most ASCII characters are transmitted as virtual keys, we
// can categorize all other ASCII characters as probably not portable.
_ => None,
}
}
}
}
AcceleratorKey::VirtKey(k) => {
use virt_keys::*;
match k {
// Special keys that either every keyboard has or everybody knows a keyboard
// might not have. Anyway, no cross-platform or keyboard-layout issues usually.
ESCAPE | F1 | F2 | F3 | F4 | F5 | F6 | F7 | F8 | F9 | F10 | F11 | INSERT
| NUMPAD0 | NUMPAD1 | NUMPAD2 | NUMPAD3 | NUMPAD4 | NUMPAD5 | NUMPAD6
| NUMPAD7 | NUMPAD8 | NUMPAD9 | SHIFT | CONTROL | MENU | SPACE | TAB | HOME
| END | PRIOR | NEXT | LEFT | UP | DOWN | RIGHT | RETURN | BACK | PAUSE
| CLEAR | DELETE | SNAPSHOT => Some(Portable),
CAPITAL => {
// CAPS LOCK doesn't fire on macOS.
Some(NonPortable(OperatingSystemRelated))
}
F12 => {
// F12 is known to be treated a bit differently at times.
Some(NonPortable(PortabilityIssue::Other))
}
// Characters
k => match u8::try_from(k.get()) {
Ok(b'A'..=b'Z' | b'0'..=b'9') => Some(Portable),
// Other basic characters don't qualify as explicitly portable.
_ => None,
},
}
}
}
}
pub fn accelerator_key(&self) -> AcceleratorKey {
AcceleratorKey::from_behavior_and_key_code(self.modifiers, self.key)
}
pub fn is_modifier_key(&self) -> bool {
use virt_keys::{CONTROL, MENU, SHIFT};
matches!(
self.accelerator_key(),
AcceleratorKey::VirtKey(CONTROL | MENU | SHIFT)
)
}
fn format_key_via_reaper(&self) -> ReaperString {
let accel = Accel {
f_virt: self.modifiers,
key: self.key,
cmd: 0,
};
Reaper::get().medium_reaper().kbd_format_key_name(accel)
}
}
impl Display for Keystroke {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let key = self.accelerator_key();
use virt_keys::{CONTROL, MENU, SHIFT};
const WIN: VirtKey = VirtKey::new(91);
use AcceleratorKey as K;
let label: Cow<str> = match key {
K::VirtKey(SHIFT) => "Shift".into(),
K::VirtKey(CONTROL) => "Ctrl/Cmd".into(),
K::VirtKey(MENU) => "Alt/Opt".into(),
K::VirtKey(WIN) => "Win/^".into(),
_ => self.format_key_via_reaper().into_string().into(),
};
f.write_str(label.as_ref())
}
}
@@ -0,0 +1,292 @@
use crate::domain::{lua_module_path_without_ext, SafeLua, ScriptColor, ScriptFeedbackEvent};
use anyhow::ensure;
use base::hash_util::NonCryptoHashSet;
use helgoboss_learn::{
FeedbackScript, FeedbackScriptInput, FeedbackScriptOutput, FeedbackValue, NumericValue,
PropProvider, PropValue,
};
use mlua::{Function, IntoLua, Lua, LuaSerdeExt, Table, Value};
use std::borrow::Cow;
use std::cell::RefCell;
use std::error::Error;
#[derive(Copy, Clone, Debug, Default)]
pub struct AdditionalLuaFeedbackScriptInput<'a> {
pub compartment_lua: Option<&'a mlua::Value>,
}
#[derive(Debug)]
pub struct LuaFeedbackScript<'a> {
lua: &'a SafeLua,
function: Function,
env: Table,
context_key: Value,
}
unsafe impl Send for LuaFeedbackScript<'_> {}
impl<'a> LuaFeedbackScript<'a> {
pub fn compile(lua: &'a SafeLua, lua_script: &str) -> anyhow::Result<Self> {
ensure!(!lua_script.trim().is_empty(), "script empty");
let env = lua.create_fresh_environment(false)?;
let function = lua.compile_as_function("Feedback script", lua_script, env.clone())?;
let script = Self {
lua,
env,
function,
context_key: "context".into_lua(lua.as_ref())?,
};
Ok(script)
}
fn feedback_internal(
&self,
input: FeedbackScriptInput,
additional_input: <LuaFeedbackScript<'a> as FeedbackScript<'a>>::AdditionalInput,
) -> anyhow::Result<FeedbackScriptOutput> {
let lua = self.lua.as_ref();
let value = lua.scope(|scope| {
// Set require function
let require = scope.create_function(move |lua, path: String| {
let val = match lua_module_path_without_ext(&path) {
LUA_FEEDBACK_SCRIPT_RUNTIME_NAME => create_lua_feedback_script_runtime(lua),
"compartment" => {
additional_input.compartment_lua.cloned().unwrap_or(Value::Nil)
},
_ => return Err(mlua::Error::runtime(format!("Feedback scripts don't support the usage of 'require' for anything else than '{LUA_FEEDBACK_SCRIPT_RUNTIME_NAME}' and 'compartment'!")))
};
Ok(val)
})
.map_err(mlua::Error::runtime)?;
self.env.raw_set("require", require)
.map_err(mlua::Error::runtime)?;
// Build input data
let context_table = {
let table = lua.create_table()?;
table.set("mode", 0)?;
let prop = scope.create_function(move |_, key: String| {
let prop_value = input.prop_provider.get_prop_value(&key);
Ok(prop_value.map(LuaPropValue))
})?;
table.set("prop", prop)?;
table
};
self.env.raw_set(self.context_key.clone(), context_table)?;
// Invoke script
let value: Value = self.function.call(())?;
Ok(value)
})?;
// Process return value
let output: LuaScriptFeedbackOutput = self.lua.as_ref().from_value(value)?;
let feedback_value = match output.feedback_event {
None => FeedbackValue::Off,
Some(e) => e.into_api_feedback_value(),
};
let api_output = FeedbackScriptOutput { feedback_value };
Ok(api_output)
}
}
pub const LUA_FEEDBACK_SCRIPT_RUNTIME_NAME: &str = "feedback_script_runtime";
pub fn create_lua_feedback_script_runtime(_lua: &Lua) -> mlua::Value {
// At the moment, the feedback script runtime doesn't contain any functions, just types.
// That means it's only relevant for autocompletion in the IDE. We can return nil.
Value::Nil
}
struct LuaPropValue(PropValue);
impl IntoLua for LuaPropValue {
fn into_lua(self, lua: &Lua) -> mlua::Result<Value> {
match self.0 {
PropValue::Normalized(p) => p.get().into_lua(lua),
PropValue::Index(i) => i.into_lua(lua),
PropValue::Numeric(NumericValue::Decimal(i)) => i.into_lua(lua),
PropValue::Numeric(NumericValue::Discrete(i)) => i.into_lua(lua),
PropValue::Boolean(state) => state.into_lua(lua),
PropValue::Text(t) => t.into_lua(lua),
PropValue::Color(c) => {
let script_color = ScriptColor::from(c);
lua.to_value(&script_color)
}
PropValue::DurationInMillis(d) => d.into_lua(lua),
}
}
}
impl<'a> FeedbackScript<'a> for LuaFeedbackScript<'a> {
type AdditionalInput = AdditionalLuaFeedbackScriptInput<'a>;
fn feedback(
&self,
input: FeedbackScriptInput,
additional_input: Self::AdditionalInput,
) -> Result<FeedbackScriptOutput, Cow<'static, str>> {
self.feedback_internal(input, additional_input)
.map_err(|e| e.to_string().into())
}
fn used_props(&self) -> Result<NonCryptoHashSet<String>, Box<dyn Error>> {
let prop_provider = TrackingPropProvider::default();
let input = FeedbackScriptInput {
prop_provider: &prop_provider,
};
self.feedback_internal(input, Default::default())?;
Ok(prop_provider.used_props.take())
}
}
#[derive(Default)]
struct TrackingPropProvider {
used_props: RefCell<NonCryptoHashSet<String>>,
}
impl PropProvider for TrackingPropProvider {
fn get_prop_value(&self, key: &str) -> Option<PropValue> {
self.used_props.borrow_mut().insert(key.to_string());
None
}
}
#[derive(Clone, serde::Serialize, serde::Deserialize)]
struct LuaScriptFeedbackOutput {
feedback_event: Option<ScriptFeedbackEvent>,
}
#[cfg(test)]
mod tests {
use super::*;
use helgoboss_learn::{
AbsoluteValue, FeedbackStyle, NumericFeedbackValue, PropValue, RgbColor,
TextualFeedbackValue, UnitValue,
};
#[test]
fn used_props() {
// Given
let text = r#"
local foo = context.prop("bye")
local bla = context.prop("hello")
return {
feedback_event = nil
}
"#;
let lua = SafeLua::new().unwrap();
let script = LuaFeedbackScript::compile(&lua, text).unwrap();
// When
let used_props = script.used_props().unwrap();
// Then
let expected: NonCryptoHashSet<_> = ["hello".to_string(), "bye".to_string()]
.into_iter()
.collect();
assert_eq!(used_props, expected);
}
#[test]
fn off_feedback() {
// Given
let text = r#"
return {
feedback_event = nil
}
"#;
let lua = SafeLua::new().unwrap();
let script = LuaFeedbackScript::compile(&lua, text).unwrap();
// When
let input = FeedbackScriptInput {
prop_provider: &|_: &str| None,
};
let output = script.feedback(input, Default::default()).unwrap();
// Then
assert_eq!(output.feedback_value, FeedbackValue::Off);
}
#[test]
fn numeric_feedback() {
// Given
let text = r#"
return {
feedback_event = {
value = 5,
color = { r = 23, g = 5, b = 122 },
},
}
"#;
let lua = SafeLua::new().unwrap();
let script = LuaFeedbackScript::compile(&lua, text).unwrap();
// When
let input = FeedbackScriptInput {
prop_provider: &|_: &str| None,
};
let output = script.feedback(input, Default::default()).unwrap();
// Then
assert_eq!(
output.feedback_value,
FeedbackValue::Numeric(NumericFeedbackValue::new(
FeedbackStyle {
color: Some(RgbColor::new(23, 5, 122)),
background_color: None,
},
AbsoluteValue::Continuous(UnitValue::MAX)
))
);
}
#[test]
fn text_feedback() {
// Given
let text = r#"
return {
feedback_event = {
value = "hello"
},
}
"#;
let lua = SafeLua::new().unwrap();
let script = LuaFeedbackScript::compile(&lua, text).unwrap();
// When
let input = FeedbackScriptInput {
prop_provider: &|_: &str| None,
};
let output = script.feedback(input, Default::default()).unwrap();
// Then
assert_eq!(
output.feedback_value,
FeedbackValue::Textual(TextualFeedbackValue::new(
FeedbackStyle::default(),
"hello".into()
))
);
}
#[test]
fn text_feedback_with_props() {
// Given
let text = r#"
return {
feedback_event = {
value = context.prop("name")
},
}
"#;
let lua = SafeLua::new().unwrap();
let script = LuaFeedbackScript::compile(&lua, text).unwrap();
// When
let input = FeedbackScriptInput {
prop_provider: &|key: &str| match key {
"name" => Some(PropValue::Text("hello".into())),
_ => None,
},
};
let output = script.feedback(input, Default::default()).unwrap();
// Then
assert_eq!(
output.feedback_value,
FeedbackValue::Textual(TextualFeedbackValue::new(
FeedbackStyle::default(),
"hello".into()
))
);
}
}
@@ -0,0 +1,293 @@
use crate::domain::{lua_module_path_without_ext, SafeLua, ScriptColor, ScriptFeedbackEvent};
use anyhow::ensure;
use helgoboss_learn::{
AbsoluteValue, FeedbackValue, MidiSourceAddress, MidiSourceScript, MidiSourceScriptOutcome,
RawMidiEvent,
};
use mlua::{Function, IntoLua, Lua, LuaSerdeExt, Table, Value};
use std::borrow::Cow;
#[derive(Copy, Clone, Debug, Default)]
pub struct AdditionalLuaMidiSourceScriptInput<'a> {
pub compartment_lua: Option<&'a mlua::Value>,
}
#[derive(Debug)]
pub struct LuaMidiSourceScript<'lua> {
lua: &'lua SafeLua,
function: Function,
env: Table,
y_key: Value,
context_key: Value,
}
unsafe impl Send for LuaMidiSourceScript<'_> {}
impl<'lua> LuaMidiSourceScript<'lua> {
pub fn compile(lua: &'lua SafeLua, lua_script: &str) -> anyhow::Result<Self> {
ensure!(!lua_script.trim().is_empty(), "script empty");
let env = lua.create_fresh_environment(false)?;
// Compile
let function = lua.compile_as_function("MIDI source script", lua_script, env.clone())?;
let script = Self {
lua,
env,
function,
y_key: "y".into_lua(lua.as_ref())?,
context_key: "context".into_lua(lua.as_ref())?,
};
Ok(script)
}
}
#[derive(serde::Serialize)]
struct ScriptContext {
feedback_event: ScriptFeedbackEvent,
}
impl<'a, 'lua: 'a> MidiSourceScript<'a> for LuaMidiSourceScript<'lua> {
type AdditionalInput = AdditionalLuaMidiSourceScriptInput<'a>;
fn execute(
&self,
input_value: FeedbackValue,
additional_input: Self::AdditionalInput,
) -> Result<MidiSourceScriptOutcome, Cow<'static, str>> {
// TODO-medium We don't limit the time of each execution at the moment because not sure
// how expensive this measurement is. But it would actually be useful to do it for MIDI
// scripts!
// Build input data
let context = ScriptContext {
feedback_event: ScriptFeedbackEvent {
value: None,
color: input_value.color().map(ScriptColor::from),
background_color: input_value.background_color().map(ScriptColor::from),
},
};
let y_value = match input_value {
FeedbackValue::Off => Value::Nil,
FeedbackValue::Numeric(n) => match n.value {
AbsoluteValue::Continuous(v) => Value::Number(v.get()),
AbsoluteValue::Discrete(f) => Value::Integer(f.actual() as _),
},
FeedbackValue::Textual(v) => v
.text
.into_lua(self.lua.as_ref())
.map_err(|_| "couldn't convert string to Lua string")?,
FeedbackValue::Complex(v) => self
.lua
.as_ref()
.to_value(&v.value)
.map_err(|_| "couldn't convert complex value to Lua value")?,
};
// Set input data as variables "y" and "context".
self.env
.raw_set(self.y_key.clone(), y_value)
.map_err(|_| "couldn't set y variable")?;
// This is important, otherwise e.g. a None color ends up as some userdata and not nil.
let mut serialize_options = mlua::SerializeOptions::new();
serialize_options.serialize_none_to_null = false;
serialize_options.serialize_unit_to_null = false;
let context_lua_value = self
.lua
.as_ref()
.to_value_with(&context, serialize_options)
.unwrap();
self.env
.raw_set(self.context_key.clone(), context_lua_value)
.map_err(|_| "couldn't set context variable")?;
// The rest is scoped because we want to create a scoped function
let value = self.lua
.as_ref()
.scope(|scope| {
// Set require function
let require = scope.create_function(move |lua, path: String| {
let val = match lua_module_path_without_ext(&path) {
LUA_MIDI_SCRIPT_SOURCE_RUNTIME_NAME => create_lua_midi_script_source_runtime(lua),
"compartment" => {
additional_input.compartment_lua.cloned().unwrap_or(Value::Nil)
},
_ => return Err(mlua::Error::runtime(format!("MIDI scripts don't support the usage of 'require' for anything else than '{LUA_MIDI_SCRIPT_SOURCE_RUNTIME_NAME}' and 'compartment'!")))
};
Ok(val)
})
.map_err(mlua::Error::runtime)?;
self.env.raw_set("require", require)
.map_err(mlua::Error::runtime)?;
// Invoke script
let value: Value = self.function.call(()).map_err(mlua::Error::runtime)?;
Ok(value)
})
.map_err(|e| {
let error = e.to_string();
tracing::debug!(msg = "Failed to execute Lua MIDI source script", %error);
error
})?;
// Process return value
let outcome: LuaScriptOutcome = self
.lua
.as_ref()
.from_value(value)
.map_err(|_| "Lua script result has wrong type")?;
let events = outcome
.messages
.into_iter()
.flat_map(|msg| RawMidiEvent::try_from_slice(0, &msg))
.collect();
let outcome = MidiSourceScriptOutcome {
address: outcome
.address
.map(|bytes| MidiSourceAddress::Script { bytes }),
events,
};
Ok(outcome)
}
}
#[derive(Clone, serde::Serialize, serde::Deserialize)]
struct LuaScriptOutcome {
address: Option<u64>,
messages: Vec<Vec<u8>>,
}
pub fn create_lua_midi_script_source_runtime(_lua: &Lua) -> mlua::Value {
// At the moment, the MIDI script source runtime doesn't contain any functions, just types.
// That means it's only relevant for autocompletion in the IDE. We can return nil.
Value::Nil
}
pub const LUA_MIDI_SCRIPT_SOURCE_RUNTIME_NAME: &str = "midi_script_source_runtime";
#[cfg(test)]
mod tests {
use super::*;
use helgoboss_learn::{
FeedbackStyle, NumericFeedbackValue, RgbColor, TextualFeedbackValue, UnitValue,
};
#[test]
fn basics() {
// Given
let text = "
return {
address = 0x4bb0,
messages = {
{ 0xb0, 0x4b, math.floor(y * 10) }
}
}
";
let lua = SafeLua::new().unwrap();
let script = LuaMidiSourceScript::compile(&lua, text).unwrap();
// When
let fb_value = NumericFeedbackValue::new(
FeedbackStyle::default(),
AbsoluteValue::Continuous(UnitValue::new(0.5)),
);
let outcome = script
.execute(FeedbackValue::Numeric(fb_value), Default::default())
.unwrap();
// Then
assert_eq!(
outcome.address,
Some(MidiSourceAddress::Script { bytes: 0x4bb0 })
);
assert_eq!(
outcome.events,
vec![RawMidiEvent::try_from_slice(0, &[0xb0, 0x4b, 5]).unwrap()]
);
}
#[test]
fn text_feedback_value() {
// Given
let text = "
local lookup_table = {
playing = 5,
stopped = 6,
paused = 7,
}
return {
messages = {
{ 0xb0, 0x4b, lookup_table[y] or 0 }
}
}
";
let lua = SafeLua::new().unwrap();
let script = LuaMidiSourceScript::compile(&lua, text).unwrap();
// When
let matched_outcome = script
.execute(
FeedbackValue::Textual(TextualFeedbackValue::new(
FeedbackStyle::default(),
"playing".into(),
)),
Default::default(),
)
.unwrap();
let unmatched_outcome = script
.execute(
FeedbackValue::Numeric(NumericFeedbackValue::new(
FeedbackStyle::default(),
AbsoluteValue::Continuous(UnitValue::MAX),
)),
Default::default(),
)
.unwrap();
// Then
assert_eq!(matched_outcome.address, None);
assert_eq!(
matched_outcome.events,
vec![RawMidiEvent::try_from_slice(0, &[0xb0, 0x4b, 5]).unwrap()]
);
assert_eq!(unmatched_outcome.address, None);
assert_eq!(
unmatched_outcome.events,
vec![RawMidiEvent::try_from_slice(0, &[0xb0, 0x4b, 0]).unwrap()]
);
}
#[test]
fn colors() {
// Given
let text = "
local color = context.feedback_event.color
if color == nil then
-- This means no specific color is set. Choose whatever you need.
color = { r = 0, g = 0, b = 0 }
end
return {
-- A unique number that identifies the LED/display.
-- (Necessary if you want correct lights-off behavior and coordination
-- between multiple mappings using the same LED/display).
address = 0x4b,
-- Whatever messages your device needs to set that color.
messages = {
{ 0xf0, 0x02, 0x4b, color.r, color.g, color.b, 0xf7 }
}
}
";
let lua = SafeLua::new().unwrap();
let script = LuaMidiSourceScript::compile(&lua, text).unwrap();
// When
let style = FeedbackStyle {
color: Some(RgbColor::new(255, 0, 255)),
background_color: None,
};
let value = NumericFeedbackValue::new(style, Default::default());
let outcome = script
.execute(FeedbackValue::Numeric(value), Default::default())
.unwrap();
// Then
assert_eq!(
outcome.address,
Some(MidiSourceAddress::Script { bytes: 0x4b })
);
assert_eq!(
outcome.events,
vec![
RawMidiEvent::try_from_slice(0, &[0xf0, 0x02, 0x4b, 0xff, 0x00, 0xff, 0xf7])
.unwrap()
]
);
}
}
@@ -0,0 +1,277 @@
use crate::domain::{compile_and_execute, create_fresh_environment};
use anyhow::{bail, Context};
use auto_impl::auto_impl;
use camino::{Utf8Path, Utf8PathBuf};
use include_dir::Dir;
use mlua::{Function, Lua, Value};
use std::borrow::Cow;
use std::cell::RefCell;
use std::fs;
use std::rc::Rc;
/// Allows executing Lua code as a module that may require other modules.
pub struct LuaModuleContainer<F> {
finder: Result<F, &'static str>,
// modules: NonCryptoHashMap<String, Value<'a>>,
}
/// Trait for resolving Lua modules.
#[auto_impl(Rc)]
pub trait LuaModuleFinder {
/// Returns a short information that let's the user know what's the root of the module tree (e.g. a path).
fn module_root_path(&self) -> String;
/// Returns the source of the Lua module at the given path or `None` if Lua module not found or doesn't have
/// UTF8-encoded content.
///
/// Requirements:
///
/// - The passed path must not start with a slash.
/// - The passed path should not contain .. or . components. If they do, behavior is undefined.
fn find_source_by_path(&self, path: &str) -> Option<Cow<'static, str>>;
}
impl<F> LuaModuleContainer<F>
where
F: LuaModuleFinder + Clone + 'static,
{
/// Creates the module container using the given module finder.
///
/// If you pass `None`, executing Lua code will still work but any usage of `require` will yield a readable error
/// message. This way, we can inform users in scenarios where `require` intentionally is not allowed.
pub fn new(finder: Result<F, &'static str>) -> Self {
Self { finder }
}
pub fn execute_as_module(
&self,
lua: &Lua,
normalized_path: Option<String>,
display_name: String,
code: &str,
) -> anyhow::Result<Value> {
execute_as_module(
lua,
normalized_path,
display_name,
code,
self.finder.clone(),
SharedAccumulator::default(),
)
}
}
#[derive(Default)]
struct Accumulator {
required_modules_stack: Vec<String>,
}
impl Accumulator {
/// The given module must be normalized, i.e. it should contain the extension.
pub fn push_module(&mut self, normalized_path: String) -> anyhow::Result<()> {
let stack = &mut self.required_modules_stack;
tracing::debug!(msg = "Pushing module onto stack", %normalized_path, ?stack);
if stack.iter().any(|path| path == &normalized_path) {
bail!("Detected cyclic Lua module dependency: {normalized_path}");
}
stack.push(normalized_path);
Ok(())
}
pub fn pop_module(&mut self) {
let stack = &mut self.required_modules_stack;
tracing::debug!(msg = "Popping top module from stack", ?stack);
stack.pop();
}
}
type SharedAccumulator = Rc<RefCell<Accumulator>>;
fn find_and_execute_module(
lua: &Lua,
finder: impl LuaModuleFinder + Clone + 'static,
accumulator: SharedAccumulator,
required_path: &str,
) -> anyhow::Result<Value> {
// Validate
let root_info = || format!("\n\nModule root path: {}", finder.module_root_path());
let path = Utf8Path::new(required_path);
if path.is_absolute() {
bail!("Required paths must not start with a slash. They are always relative to the preset sub directory.{}", root_info());
}
if path
.components()
.any(|comp| matches!(comp.as_str(), "." | ".."))
{
bail!("Required paths containing . or .. are forbidden. They are always relative to the preset sub directory.{}", root_info());
}
// Substitute preset runtime stub
if lua_module_path_without_ext(path.as_str()) == LUA_PRESET_RUNTIME_NAME {
let table = lua.create_table()?;
let finder = finder.clone();
let include_str = lua.create_function(move |_, path: String| {
let content = finder
.find_source_by_path(&path)
.map(|content| content.to_string());
Ok(content)
})?;
table.set("include_str", include_str)?;
return Ok(Value::Table(table));
}
// Find module and get its source
let (normalized_path, source) = if path
.extension()
.is_some_and(|ext| matches!(ext, "luau" | "lua"))
{
// Extension given. Just get file directly.
let source = finder
.find_source_by_path(path.as_str())
.with_context(|| format!("Couldn't find Lua module [{path}].{}", root_info()))?;
(path.to_string(), source)
} else {
// No extension given. Try ".luau" and ".lua".
["luau", "lua"]
.into_iter()
.find_map(|ext| {
let path_with_extension = format!("{path}.{ext}");
tracing::debug!(msg = "Finding module by path...", %path_with_extension);
let source = finder.find_source_by_path(&path_with_extension)?;
Some((path_with_extension, source))
})
.with_context(|| {
format!(
"Couldn't find Lua module [{path}]. Tried with extension \".lua\" and \".luau\".{}", root_info()
)
})?
};
// Execute module
execute_as_module(
lua,
Some(normalized_path.clone()),
normalized_path,
source.as_ref(),
Ok(finder),
accumulator,
)
}
pub fn lua_module_path_without_ext(path: &str) -> &str {
path.strip_suffix(".luau")
.or_else(|| path.strip_suffix(".lua"))
.unwrap_or(path)
}
fn execute_as_module(
lua: &Lua,
normalized_path: Option<String>,
display_name: String,
code: &str,
finder: Result<impl LuaModuleFinder + Clone + 'static, &'static str>,
accumulator: SharedAccumulator,
) -> anyhow::Result<Value> {
let env = create_fresh_environment(lua, true)?;
let require = create_require_function(lua, finder, accumulator.clone())?;
env.set("require", require)?;
let pop_later = if let Some(p) = normalized_path {
accumulator.borrow_mut().push_module(p)?;
true
} else {
false
};
let value = compile_and_execute(lua, display_name.clone(), code, env)
.with_context(|| format!("Couldn't compile and execute Lua module {display_name}"))?;
if pop_later {
accumulator.borrow_mut().pop_module();
}
Ok(value)
// TODO-medium-performance Instead of just detecting cycles, we could cache the module execution result and return
// it whenever it's queried again.
// match self.modules.entry(path) {
// Entry::Occupied(e) => Ok(e.into_mut()),
// Entry::Vacant(e) => {
// let path = e.key();
// let source = self
// .finder
// .find_source_by_path(path)
// .with_context(|| format!("Couldn't find Lua module {path}"))?;
// let env = safe_lua.create_fresh_environment(true)?;
// let value = safe_lua
// .compile_and_execute("Module", source.as_ref(), env)
// .with_context(|| format!("Couldn't compile and execute Lua module {path}"))?;
// Ok(e.insert(value))
// }
// }
}
fn create_require_function(
lua: &Lua,
finder: Result<impl LuaModuleFinder + Clone + 'static, &'static str>,
accumulator: SharedAccumulator,
) -> anyhow::Result<Function> {
let require = lua.create_function_mut(move |lua, required_path: String| {
let finder = finder.clone().map_err(mlua::Error::runtime)?;
let value =
find_and_execute_module(lua, finder.clone(), accumulator.clone(), &required_path)
.map_err(|e| mlua::Error::runtime(format!("{e:#}")))?;
Ok(value)
})?;
Ok(require)
}
/// Files Lua modules within a specified binary-included directory.
#[derive(Clone)]
pub struct IncludedDirLuaModuleFinder {
dir: Dir<'static>,
}
impl IncludedDirLuaModuleFinder {
pub fn new(dir: Dir<'static>) -> Self {
Self { dir }
}
}
impl LuaModuleFinder for IncludedDirLuaModuleFinder {
fn module_root_path(&self) -> String {
"factory:/".to_string()
}
fn find_source_by_path(&self, path: &str) -> Option<Cow<'static, str>> {
let contents = self.dir.get_file(path)?.contents_utf8()?;
Some(contents.into())
}
}
/// Files Lua modules within a specified file-system directory.
#[derive(Clone)]
pub struct FsDirLuaModuleFinder {
dir: Utf8PathBuf,
}
impl FsDirLuaModuleFinder {
pub fn new(dir: Utf8PathBuf) -> Self {
Self { dir }
}
}
impl LuaModuleFinder for FsDirLuaModuleFinder {
fn module_root_path(&self) -> String {
self.dir.to_string()
}
fn find_source_by_path(&self, path: &str) -> Option<Cow<'static, str>> {
let path = Utf8Path::new(path);
// It's a precondition by contract that the given path is not absolute. However, in order to fail
// fast in case this precondition is missed, we check again here. Because on a file system, absolute
// files can actually work, but we don't want it to work.
if path.is_absolute() {
return None;
}
let absolute_path = self.dir.join(path);
tracing::debug!(msg = "find_source_by_path", ?absolute_path);
let content = fs::read_to_string(absolute_path).ok()?;
tracing::debug!(msg = "find_source_by_path successful");
Some(content.into())
}
}
const LUA_PRESET_RUNTIME_NAME: &str = "preset_runtime";
@@ -0,0 +1,66 @@
use helgoboss_learn::{
AbsoluteValue, ComplexFeedbackValue, FeedbackStyle, FeedbackValue, NumericFeedbackValue,
RgbColor, TextualFeedbackValue, UnitValue,
};
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub struct ScriptFeedbackEvent {
pub value: Option<ScriptFeedbackValue>,
pub color: Option<ScriptColor>,
pub background_color: Option<ScriptColor>,
}
#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[serde(untagged)]
pub enum ScriptFeedbackValue {
Unit(f64),
Text(String),
Complex(serde_json::Value),
}
impl ScriptFeedbackEvent {
pub fn into_api_feedback_value(self) -> FeedbackValue<'static> {
let style = FeedbackStyle {
color: self.color.map(|c| c.into()),
background_color: self.background_color.map(|c| c.into()),
};
match self.value {
None => FeedbackValue::Off,
Some(ScriptFeedbackValue::Unit(v)) => {
FeedbackValue::Numeric(NumericFeedbackValue::new(
style,
AbsoluteValue::Continuous(UnitValue::new_clamped(v)),
))
}
Some(ScriptFeedbackValue::Text(t)) => {
FeedbackValue::Textual(TextualFeedbackValue::new(style, t.into()))
}
Some(ScriptFeedbackValue::Complex(v)) => {
FeedbackValue::Complex(ComplexFeedbackValue::new(style, v))
}
}
}
}
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub struct ScriptColor {
pub r: u8,
pub g: u8,
pub b: u8,
}
impl From<RgbColor> for ScriptColor {
fn from(c: RgbColor) -> Self {
Self {
r: c.r(),
g: c.g(),
b: c.b(),
}
}
}
impl From<ScriptColor> for RgbColor {
fn from(c: ScriptColor) -> Self {
Self::new(c.r, c.g, c.b)
}
}
@@ -0,0 +1,280 @@
use anyhow::anyhow;
use mlua::serde::de;
use mlua::{ChunkMode, Function, Lua, Table, Value, VmState};
use serde::de::DeserializeOwned;
use std::error::Error;
use std::sync::Arc;
use std::time::{Duration, Instant};
#[derive(Debug)]
pub struct SafeLua(Lua);
impl SafeLua {
/// Creates the Lua state.
pub fn new() -> anyhow::Result<Self> {
let lua = Lua::new();
// TODO-medium Maybe we can avoid having to build the safe Lua environment for each
// compilation/create-fresh-env step by doing something like the following.
// // Build safe globals based on original globals
// let safe_globals = build_safe_lua_env(&lua, lua.globals())?;
// // Empty original globals
// let original_keys: Vec<Value> = lua
// .globals()
// .pairs::<Value, Value>()
// .flat_map(|res| res?.0)
// .collect();
// let globals = lua.globals();
// for key in original_keys {
// globals.raw_remove(key)?;
// }
// // Fill original globals with safe globals
// for pair in safe_globals.pairs::<Value, Value>() {
// let (key, value) = pair?;
// globals[key] = value;
// }
Ok(Self(lua))
}
pub fn from_value<T>(value: Value) -> anyhow::Result<T>
where
T: DeserializeOwned,
{
let result = T::deserialize(de::Deserializer::new(value))?;
Ok(result)
}
/// Compiles as a function with return value (for later execution).
pub fn compile_as_function(
&self,
name: &str,
code: &str,
env: Table,
) -> anyhow::Result<Function> {
let chunk = self
.0
.load(code)
.set_name(name)
.set_environment(env)
.set_mode(ChunkMode::Text);
let function = chunk.into_function()?;
Ok(function)
}
/// Compiles and executes the given code in one go (shouldn't be used for repeated execution!).
pub fn compile_and_execute(
&self,
display_name: String,
code: &str,
env: Table,
) -> anyhow::Result<Value> {
compile_and_execute(&self.0, display_name, code, env)
}
/// Creates a fresh environment for this Lua state.
///
/// Setting `allow_side_effects` unlocks a few more vars, but only use that if you boot up a
/// fresh Lua state for each execution.
pub fn create_fresh_environment(&self, allow_side_effects: bool) -> anyhow::Result<Table> {
create_fresh_environment(&self.0, allow_side_effects)
}
/// Call before executing user code in order to prevent code from taking too long to execute.
pub fn start_execution_time_limit_countdown(&self) {
const MAX_DURATION: Duration = Duration::from_millis(1000);
let instant = Instant::now();
self.0.set_interrupt(move |_lua| {
if instant.elapsed() > MAX_DURATION {
Err(mlua::Error::ExternalError(Arc::new(
RealearnScriptError::Timeout,
)))
} else {
Ok(VmState::Continue)
}
});
}
}
/// Creates a fresh environment for this Lua state.
///
/// Setting `allow_side_effects` unlocks a few more vars, but only use that if you boot up a
/// fresh Lua state for each execution.
pub fn create_fresh_environment(lua: &Lua, allow_side_effects: bool) -> anyhow::Result<Table> {
build_safe_lua_env(lua, lua.globals(), allow_side_effects)
}
/// Compiles and executes the given code in one go (shouldn't be used for repeated execution!).
pub fn compile_and_execute(
lua: &Lua,
display_name: String,
code: &str,
env: Table,
) -> anyhow::Result<Value> {
let lua_chunk = lua
.load(code)
.set_name(display_name)
.set_mode(ChunkMode::Text)
.set_environment(env);
let value = lua_chunk.eval().map_err(|e| match e {
// Box the cause if it's a callback error (used for the execution time limit feature).
mlua::Error::CallbackError { cause, .. } => {
anyhow!(cause)
}
e => anyhow!(e),
})?;
Ok(value)
}
impl AsRef<Lua> for SafeLua {
fn as_ref(&self) -> &Lua {
&self.0
}
}
#[derive(Debug, derive_more::Display)]
enum RealearnScriptError {
#[display(fmt = "Helgobox script took too long to execute")]
Timeout,
}
impl Error for RealearnScriptError {}
/// Creates a Lua environment in which we can't execute potentially malicious code
/// (by only including safe functions according to http://lua-users.org/wiki/SandBoxes).
///
/// Setting `allow_side_effects` unlocks a few more vars, but only use that if you boot up a
/// fresh Lua state for each execution.
fn build_safe_lua_env(
lua: &Lua,
original_env: Table,
allow_side_effects: bool,
) -> anyhow::Result<Table> {
let safe_env = lua.create_table()?;
for var in SAFE_LUA_VARS {
copy_var_to_table(lua, &safe_env, &original_env, var)?;
}
if allow_side_effects {
for var in EXTENDED_SAFE_LUA_VARS {
copy_var_to_table(lua, &safe_env, &original_env, var)?;
}
}
Ok(safe_env)
}
fn copy_var_to_table(
lua: &Lua,
dest_table: &Table,
src_table: &Table,
var: &str,
) -> anyhow::Result<()> {
if let Some(dot_index) = var.find('.') {
// Nested variable
let parent_var = &var[0..dot_index];
let nested_dest_table = if let Ok(t) = dest_table.get(parent_var) {
t
} else {
let new_table = lua.create_table()?;
dest_table.set(parent_var, new_table.clone())?;
new_table
};
let nested_src_table: Table = src_table.get(parent_var)?;
let child_var = &var[dot_index + 1..];
copy_var_to_table(lua, &nested_dest_table, &nested_src_table, child_var)?;
Ok(())
} else {
// Leaf variable
let original_value: Value = src_table.get(var)?;
dest_table.set(var, original_value)?;
Ok(())
}
}
/// Safe Lua vars according to http://lua-users.org/wiki/SandBoxes.
///
/// Even a bit more restrictive because we don't include `io` and `coroutine`.
const SAFE_LUA_VARS: &[&str] = &[
"assert",
"error",
"ipairs",
"next",
"pairs",
"pcall",
"print",
"select",
"tonumber",
"tostring",
"type",
"unpack",
"_VERSION",
"xpcall",
"string.byte",
"string.char",
"string.find",
"string.format",
"string.gmatch",
"string.gsub",
"string.len",
"string.lower",
"string.match",
"string.rep",
"string.reverse",
"string.sub",
"string.upper",
// "table.clone" is available in Luau only
"table.clone",
"table.insert",
"table.maxn",
"table.remove",
"table.sort",
"math.abs",
"math.acos",
"math.asin",
"math.atan",
"math.atan2",
"math.ceil",
"math.cos",
"math.cosh",
"math.deg",
"math.exp",
"math.floor",
"math.fmod",
"math.frexp",
"math.huge",
"math.ldexp",
"math.log",
"math.log10",
"math.max",
"math.min",
"math.modf",
"math.pi",
"math.pow",
"math.rad",
"math.random",
"math.sin",
"math.sinh",
"math.sqrt",
"math.tan",
"math.tanh",
"os.clock",
"os.difftime",
"os.time",
// bit32 (Lua 5.2 & Luau intersection)
"bit32.arshift",
"bit32.band",
"bit32.bnot",
"bit32.bor",
"bit32.btest",
"bit32.bxor",
"bit32.extract",
"bit32.lrotate",
"bit32.lshift",
"bit32.replace",
"bit32.rrotate",
"bit32.rshift",
];
/// An extended set of Lua vars that can be considered safe under certain circumstances.
///
/// Some vars are unsafe according to http://lua-users.org/wiki/SandBoxes, but only because of the
/// side effects that it could have on other code executed in the same Lua state. In situations
/// where we create a fresh Lua state everytime, this doesn't matter.
const EXTENDED_SAFE_LUA_VARS: &[&str] = &["setmetatable"];
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,145 @@
use crate::domain::{MappingId, Tag, TagScope, VirtualMappingSnapshotIdForLoad};
use base::hash_util::{NonCryptoHashMap, NonCryptoHashSet};
use base::{convert_to_identifier, SmallAsciiString};
use helgoboss_learn::AbsoluteValue;
use std::str::FromStr;
#[derive(Debug, Default)]
pub struct MappingSnapshotContainer {
snapshots: NonCryptoHashMap<MappingSnapshotId, MappingSnapshot>,
active_snapshot_id_by_tag: NonCryptoHashMap<Tag, MappingSnapshotId>,
}
impl MappingSnapshotContainer {
/// Creates the container.
pub fn new(
snapshots: NonCryptoHashMap<MappingSnapshotId, MappingSnapshot>,
active_snapshot_id_by_tag: NonCryptoHashMap<Tag, MappingSnapshotId>,
) -> Self {
Self {
snapshots,
active_snapshot_id_by_tag,
}
}
/// Updates the contents of the given snapshot.
pub fn update_snapshot(&mut self, id: MappingSnapshotId, snapshot: MappingSnapshot) {
self.snapshots.insert(id, snapshot);
}
/// Returns the last loaded snapshot ID for the given tags.
///
/// If there's no record of a last loaded snapshot for any of the given tags, it returns `None`.
///
/// If the last loaded snapshot IDs differ between the tags, it also returns `None`.
pub fn last_loaded_snapshot_id(&self, scope: &TagScope) -> Option<MappingSnapshotId> {
let active_snapshot_ids: NonCryptoHashSet<_> = scope
.tags
.iter()
.map(|tag| self.active_snapshot_id_by_tag.get(tag))
.collect();
if active_snapshot_ids.len() > 1 {
// Active snapshots differ.
return None;
}
let single_active_snapshot_id = active_snapshot_ids.iter().next()?.cloned()?;
Some(single_active_snapshot_id)
}
/// Marks the given snapshot as the active one for all tags in the given scope.
pub fn mark_snapshot_active(
&mut self,
tag_scope: &TagScope,
snapshot_id: &VirtualMappingSnapshotIdForLoad,
) {
for tag in &tag_scope.tags {
match snapshot_id {
VirtualMappingSnapshotIdForLoad::Initial => {
self.active_snapshot_id_by_tag.remove(tag);
}
VirtualMappingSnapshotIdForLoad::ById(id) => {
self.active_snapshot_id_by_tag
.insert(tag.clone(), id.clone());
}
}
}
}
/// Returns `true` if for all tags in the given scope the given snapshot is the currently
/// active one.
pub fn snapshot_is_active(
&self,
tag_scope: &TagScope,
snapshot_id: &VirtualMappingSnapshotIdForLoad,
) -> bool {
tag_scope.tags.iter().all(|tag| {
if let Some(active_snapshot_id) = self.active_snapshot_id_by_tag.get(tag) {
if let VirtualMappingSnapshotIdForLoad::ById(snapshot_id) = snapshot_id {
snapshot_id == active_snapshot_id
} else {
false
}
} else {
matches!(snapshot_id, VirtualMappingSnapshotIdForLoad::Initial)
}
})
}
pub fn active_snapshot_id_by_tag(&self) -> &NonCryptoHashMap<Tag, MappingSnapshotId> {
&self.active_snapshot_id_by_tag
}
/// Returns the snapshot contents associated with the given snapshot ID.
pub fn find_snapshot_by_id(&self, id: &MappingSnapshotId) -> Option<&MappingSnapshot> {
self.snapshots.get(id)
}
/// Returns all snapshots in this container.
pub fn snapshots(&self) -> impl Iterator<Item = (&MappingSnapshotId, &MappingSnapshot)> {
self.snapshots.iter()
}
}
#[derive(Debug, Default)]
pub struct MappingSnapshot {
target_values: NonCryptoHashMap<MappingId, AbsoluteValue>,
}
impl MappingSnapshot {
pub fn new(target_values: NonCryptoHashMap<MappingId, AbsoluteValue>) -> Self {
Self { target_values }
}
pub fn find_target_value_by_mapping_id(&self, id: MappingId) -> Option<AbsoluteValue> {
self.target_values.get(&id).copied()
}
pub fn target_values(&self) -> impl Iterator<Item = (MappingId, AbsoluteValue)> + '_ {
self.target_values
.iter()
.map(|(id, target_value)| (*id, *target_value))
}
}
#[derive(
Clone,
Eq,
PartialEq,
Ord,
PartialOrd,
Debug,
Hash,
derive_more::Display,
serde_with::SerializeDisplay,
serde_with::DeserializeFromStr,
)]
pub struct MappingSnapshotId(SmallAsciiString);
impl FromStr for MappingSnapshotId {
type Err = &'static str;
fn from_str(text: &str) -> Result<Self, Self::Err> {
let small_ascii_string = convert_to_identifier(text)?;
Ok(Self(small_ascii_string))
}
}
@@ -0,0 +1,33 @@
use crate::domain::ControlEventTimestamp;
use reaper_common_types::Bpm;
use simple_moving_average::{SumTreeSMA, SMA};
use std::convert::TryInto;
#[derive(Debug)]
pub struct MidiClockCalculator {
previous_timestamp: Option<ControlEventTimestamp>,
moving_avg_calculator: SumTreeSMA<f64, f64, 10>,
}
impl Default for MidiClockCalculator {
fn default() -> Self {
Self {
previous_timestamp: None,
moving_avg_calculator: SumTreeSMA::new(),
}
}
}
impl MidiClockCalculator {
pub fn feed(&mut self, timestamp: ControlEventTimestamp) -> Option<Bpm> {
let prev_timestamp = self.previous_timestamp.replace(timestamp)?;
let duration_since_last = timestamp - prev_timestamp;
let num_ticks_per_sec = 1.0 / duration_since_last.as_secs_f64();
let num_beats_per_sec = num_ticks_per_sec / 24.0;
let new_bpm = num_beats_per_sec * 60.0;
self.moving_avg_calculator.add_sample(new_bpm);
let avg_bpm = self.moving_avg_calculator.get_average();
let avg_bpm: Bpm = avg_bpm.try_into().ok()?;
Some(avg_bpm)
}
}
@@ -0,0 +1,128 @@
use reaper_high::Reaper;
use reaper_medium::{MidiInputDeviceId, MidiOutputDeviceId};
#[derive(Copy, Clone, Eq, PartialEq, Debug, Default)]
pub struct MidiInDevsConfig {
midiins: u128,
midiins_nowarn: u128,
}
impl MidiInDevsConfig {
pub const ALL_ENABLED: Self = Self {
midiins: u128::MAX,
midiins_nowarn: u128::MAX,
};
pub fn from_reaper() -> Self {
Self {
midiins: get_midi_dev_var("midiins"),
midiins_nowarn: get_midi_dev_var("midiins_nowarn"),
}
}
pub fn apply_to_reaper(&self) {
set_midi_dev_var("midiins", self.midiins);
set_midi_dev_var("midiins_nowarn", self.midiins_nowarn);
}
pub fn with_dev_enabled(&self, dev_id: MidiInputDeviceId) -> Self {
let index = dev_id.get();
Self {
midiins: self.midiins | (1 << index),
midiins_nowarn: self.midiins_nowarn | (1 << index),
}
}
pub fn to_ini_entries(self) -> impl Iterator<Item = (String, u32)> {
to_ini_entries("midiins", self.midiins)
.chain(to_ini_entries("midiins_nowarn", self.midiins_nowarn))
}
}
fn to_ini_entries(name: &str, devs: u128) -> impl Iterator<Item = (String, u32)> {
[
(
name.to_string(),
(devs & 0x00000000_00000000_00000000_FFFFFFFF) as u32,
),
(
format!("{name}_h"),
((devs & 0x00000000_00000000_FFFFFFFF_00000000) >> 32) as u32,
),
(
format!("{name}_x"),
((devs & 0x00000000_FFFFFFFF_00000000_00000000) >> 64) as u32,
),
(
format!("{name}_x_h"),
((devs & 0xFFFFFFFF_00000000_00000000_00000000) >> 96) as u32,
),
]
.into_iter()
}
#[derive(Copy, Clone, Eq, PartialEq, Debug, Default)]
pub struct MidiOutDevsConfig {
midiouts: u128,
midiouts_nowarn: u128,
midiouts_noreset: u128,
}
impl MidiOutDevsConfig {
pub fn from_reaper() -> Self {
Self {
midiouts: get_midi_dev_var("midiouts"),
midiouts_nowarn: get_midi_dev_var("midiouts_nowarn"),
midiouts_noreset: get_midi_dev_var("midiouts_noreset"),
}
}
pub fn apply_to_reaper(&self) {
set_midi_dev_var("midiouts", self.midiouts);
set_midi_dev_var("midiouts_nowarn", self.midiouts_nowarn);
set_midi_dev_var("midiouts_noreset", self.midiouts_noreset);
}
pub fn with_dev_enabled(&self, dev_id: MidiOutputDeviceId) -> Self {
let index = dev_id.get();
Self {
midiouts: self.midiouts | (1 << index),
midiouts_nowarn: self.midiouts_nowarn | (1 << index),
midiouts_noreset: self.midiouts_noreset | (1 << index),
}
}
pub fn to_ini_entries(self) -> impl Iterator<Item = (String, u32)> {
to_ini_entries("midiouts", self.midiouts)
.chain(to_ini_entries("midiouts_nowarn", self.midiouts_nowarn))
.chain(to_ini_entries("midiouts_noreset", self.midiouts_noreset))
}
}
fn get_midi_dev_var(name: &str) -> u128 {
let reaper = Reaper::get();
match reaper.get_preference_ref::<u128>(name) {
Ok(v) => *v,
Err(_) => {
// Older REAPER versions supported only 64 MIDI devices
match reaper.get_preference_ref::<u64>(name) {
Ok(v) => *v as u128,
Err(_) => 0,
}
}
}
}
fn set_midi_dev_var(name: &str, value: u128) {
let reaper = Reaper::get();
match reaper.get_preference_ref::<u128>(name) {
Ok(v) => *v = value,
Err(_) => {
// Older REAPER versions supported only 64 MIDI devices
match reaper.get_preference_ref::<u64>(name) {
Ok(v) => *v = value as u64,
Err(_) => {}
}
}
}
}
@@ -0,0 +1,44 @@
use crate::base::CloneAsDefault;
use crate::domain::{AdditionalLuaMidiSourceScriptInput, FlexibleMidiSourceScript};
use helgoboss_learn::{FeedbackValue, MidiSourceScript, MidiSourceScriptOutcome};
use std::borrow::Cow;
/// The helgoboss-learn MidiSource, integrated into ReaLearn.
///
/// Now this needs some explanation: Why do we wrap the MIDI source script type with
/// `CloneAsDefault<Option<...>>`!? Because the script is compiled and therefore doesn't suit itself
/// to being cloned. But we need the MidiSource to be cloneable because we clone it whenever we
/// sync the mapping(s) from the main processor to the real-time processor. Fortunately, the
/// real-time processor doesn't use the compiled scripts anyway because those scripts are
/// responsible for feedback only.
///
/// Using `Arc` sounds like a good solution at first but it means that deallocation of the compiled
/// script could be triggered *in the real-time thread*. Now, we have a custom global deallocator
/// for automatically deferring deallocation if we are in a real-time thread. **But!** We use
/// non-Rust script engines (EEL and Lua), so they are not aware of our global allocator ... and
/// that means we would still get a real-time deallocation :/ Yes, we could handle this by manually
/// sending the obsolete structs to a deallocation thread *before* the Rust wrappers around the
/// script engines are even dropped (as we did before), but go there if the real-time processor
/// doesn't even use the scripts.
///
/// Introducing a custom method (not `clone`) would be quite much effort because we can't
/// derive its usage.
type ScriptType = CloneAsDefault<Option<FlexibleMidiSourceScript<'static>>>;
pub type MidiSource = helgoboss_learn::MidiSource<ScriptType>;
impl<'a> MidiSourceScript<'a> for ScriptType {
type AdditionalInput = AdditionalLuaMidiSourceScriptInput<'a>;
fn execute(
&self,
input_value: FeedbackValue,
additional_input: Self::AdditionalInput,
) -> Result<MidiSourceScriptOutcome, Cow<'static, str>> {
let script = self
.get()
.as_ref()
.ok_or(Cow::Borrowed("script was removed on clone"))?;
script.execute(input_value, additional_input)
}
}
@@ -0,0 +1,573 @@
use helgoboss_learn::{MidiSourceValue, RawMidiEvent, SourceCharacter};
use helgoboss_midi::{
Channel, ControlChange14BitMessageScanner, ControllerNumber,
PollingParameterNumberMessageScanner, RawShortMessage, ShortMessage, ShortMessageFactory,
StructuredShortMessage, U7,
};
use reaper_medium::MidiInputDeviceId;
use std::cmp::Ordering;
use std::time::{Duration, Instant};
const MAX_CC_MSG_COUNT: usize = 10;
const MAX_CC_WAITING_TIME: Duration = Duration::from_millis(250);
#[derive(Debug)]
pub struct MidiScanner {
// Scanners for more complex MIDI message types
nrpn_scanner: PollingParameterNumberMessageScanner,
cc_14_bit_scanner: ControlChange14BitMessageScanner,
state: State,
dev_id: Option<MidiInputDeviceId>,
}
impl Default for MidiScanner {
fn default() -> Self {
Self {
nrpn_scanner: PollingParameterNumberMessageScanner::new(Duration::from_millis(1)),
cc_14_bit_scanner: Default::default(),
state: State::Initial,
dev_id: None,
}
}
}
#[derive(Debug)]
enum State {
Initial,
WaitingForMoreCcMsgs(ControlChangeState),
}
#[derive(Debug)]
struct ControlChangeState {
start_time: Instant,
channel: Channel,
controller_number: ControllerNumber,
msg_count: usize,
values: [U7; MAX_CC_MSG_COUNT],
}
impl ControlChangeState {
fn new(channel: Channel, controller_number: ControllerNumber) -> ControlChangeState {
ControlChangeState {
start_time: Instant::now(),
channel,
controller_number,
msg_count: 0,
values: [U7::MIN; MAX_CC_MSG_COUNT],
}
}
fn add_value(&mut self, value: U7) {
assert!(self.msg_count < MAX_CC_MSG_COUNT);
self.values[self.msg_count] = value;
self.msg_count += 1;
}
fn time_to_guess(&self) -> bool {
self.msg_count >= MAX_CC_MSG_COUNT || Instant::now() - self.start_time > MAX_CC_WAITING_TIME
}
fn matches(&self, channel: Channel, controller_number: ControllerNumber) -> bool {
channel == self.channel && controller_number == self.controller_number
}
}
#[derive(Clone, PartialEq, Debug)]
pub struct MidiScanResult {
pub value: MidiSourceValue<'static, RawShortMessage>,
pub dev_id: Option<MidiInputDeviceId>,
pub character: Option<SourceCharacter>,
}
impl MidiScanResult {
pub fn new(
value: MidiSourceValue<'static, RawShortMessage>,
dev_id: Option<MidiInputDeviceId>,
character: Option<SourceCharacter>,
) -> Self {
Self {
value,
dev_id,
character,
}
}
/// This allocates!
pub fn try_from_bytes(
bytes: &[u8],
dev_id: Option<MidiInputDeviceId>,
) -> Result<Self, &'static str> {
let raw_event = RawMidiEvent::try_from_slice(0, bytes)?;
// This allocates!
let res = MidiScanResult {
dev_id,
value: {
// We don't use this as feedback value.
let feedback_address_info = None;
MidiSourceValue::single_raw(feedback_address_info, raw_event)
},
character: None,
};
Ok(res)
}
}
impl MidiScanner {
pub fn feed_short(
&mut self,
msg: RawShortMessage,
dev_id: Option<MidiInputDeviceId>,
) -> Option<MidiScanResult> {
if let Some(nrpn_msg) = self.nrpn_scanner.feed(&msg)[0] {
let res = self.feed(
MidiSourceValue::<RawShortMessage>::ParameterNumber(nrpn_msg),
dev_id,
);
if res.is_some() {
return res;
}
}
if let Some(cc14_msg) = self.cc_14_bit_scanner.feed(&msg) {
let res = self.feed(
MidiSourceValue::<RawShortMessage>::ControlChange14Bit(cc14_msg),
dev_id,
);
if res.is_some() {
return res;
}
}
self.feed(MidiSourceValue::Plain(msg), dev_id)
}
fn feed(
&mut self,
source_value: MidiSourceValue<RawShortMessage>,
dev_id: Option<MidiInputDeviceId>,
) -> Option<MidiScanResult> {
// First encountered device ID rules.
if self.dev_id.is_none() {
self.dev_id = dev_id;
}
match &mut self.state {
State::Initial => {
if let MidiSourceValue::Plain(msg) = source_value {
if let StructuredShortMessage::ControlChange {
channel,
controller_number,
control_value,
} = msg.to_structured()
{
let mut cc_state = ControlChangeState::new(channel, controller_number);
cc_state.add_value(control_value);
self.state = State::WaitingForMoreCcMsgs(cc_state);
None
} else {
Some(MidiScanResult::new(
source_value.try_into_owned().ok()?,
dev_id,
None,
))
}
} else {
Some(MidiScanResult::new(
source_value.try_into_owned().ok()?,
dev_id,
None,
))
}
}
State::WaitingForMoreCcMsgs(cc_state) => {
if let MidiSourceValue::Plain(msg) = source_value {
if let StructuredShortMessage::ControlChange {
channel,
controller_number,
control_value,
} = msg.to_structured()
{
if cc_state.matches(channel, controller_number) {
cc_state.add_value(control_value);
}
}
self.guess_or_not()
} else {
// Looks like in the meantime, the composite scanners ((N)RPN or
// 14-bit CC) have figured out that the combination is a composite
// message. This fixes https://github.com/helgoboss/helgobox/issues/95.
let res =
MidiScanResult::new(source_value.try_into_owned().ok()?, dev_id, None);
self.reset();
Some(res)
}
}
}
}
pub fn poll(&mut self) -> Option<MidiScanResult> {
for ch in 0..16 {
if let Some(nrpn_msg) = self.nrpn_scanner.poll(Channel::new(ch)) {
let source_value = MidiSourceValue::<RawShortMessage>::ParameterNumber(nrpn_msg);
let res = self.feed(source_value, None);
if res.is_some() {
return res;
}
}
}
self.guess_or_not()
}
pub fn reset(&mut self) {
self.nrpn_scanner.reset();
self.cc_14_bit_scanner.reset();
self.state = State::Initial;
}
fn guess_or_not(&mut self) -> Option<MidiScanResult> {
if let State::WaitingForMoreCcMsgs(cc_state) = &self.state {
if cc_state.time_to_guess() {
let guessed_result = guess(cc_state, self.dev_id);
self.reset();
Some(guessed_result)
} else {
None
}
} else {
None
}
}
}
fn guess(cc_state: &ControlChangeState, dev_id: Option<MidiInputDeviceId>) -> MidiScanResult {
let first_cc_msg = RawShortMessage::control_change(
cc_state.channel,
cc_state.controller_number,
cc_state.values[0],
);
MidiScanResult {
value: MidiSourceValue::Plain(first_cc_msg),
dev_id,
character: Some(guess_custom_character(
&cc_state.values[0..cc_state.msg_count - 1],
)),
}
}
fn contains_direction_change(values: &[U7]) -> bool {
#[derive(Copy, Clone, PartialEq)]
enum Direction {
Clockwise,
CounterClockwise,
}
fn determine_direction(a: U7, b: U7) -> Option<Direction> {
use Direction::*;
use Ordering::*;
match b.cmp(&a) {
Greater => Some(Clockwise),
Less => Some(CounterClockwise),
Equal => None,
}
}
let mut direction_so_far: Option<Direction> = None;
for i in 1..values.len() {
let new_direction = determine_direction(values[i - 1], values[i]);
if new_direction.is_none() {
continue;
}
if direction_so_far.is_none() {
direction_so_far = new_direction;
continue;
}
if new_direction != direction_so_far {
return true;
}
}
false
}
fn contains_consecutive_duplicates(values: &[U7]) -> bool {
for i in 1..values.len() {
if values[i] == values[i - 1] {
return true;
}
}
false
}
fn guess_custom_character(values: &[U7]) -> SourceCharacter {
use SourceCharacter::*;
// We don't just interpret 127 or 100 as button because we consider typical keyboard keys also
// as buttons. They can be velocity-sensitive and therefore transmit any value.
#[allow(clippy::if_same_then_else)]
if values.len() == 1 {
// Only one message received. Looks like a button has been pressed and not released.
MomentaryButton
} else if values.len() == 2 && values[1] == U7::MIN {
// Two messages received and second message has value 0. Looks like a button has been
// pressed and released.
MomentaryButton
} else {
// Multiple messages received. Button character is ruled out already. Check continuity.
if contains_direction_change(values) {
// A direction change means it's very likely a (relative) encoder.
guess_encoder_type(values)
} else if contains_consecutive_duplicates(values) {
if values.contains(&U7::MIN) {
// For relative, zero means "don't do anything" - which is a bit pointless
// to send. So it's probably an encoder which is
// configured to transmit absolute values hitting
// the lower boundary.
RangeElement
} else if values.contains(&U7::MAX) {
// Here we rely on the fact that the user should turn clock-wise. So it
// can't be relative type 1 because 127 means
// decrement. It's also unlikely to be the
// other relative types because this would happen with extreme acceleration
// only. So it's probably an encoder which is configured to transmit
// absolute values hitting the upper boundary.
RangeElement
} else {
guess_encoder_type(values)
}
} else {
// Was continuous without duplicates until now so it's probably a knob/fader.
SourceCharacter::RangeElement
}
}
}
/// Unfortunately, encoder type 3 clockwise movement is not really distinguishable from 1 or 2.
/// So we won't support its detection.
fn guess_encoder_type(values: &[U7]) -> SourceCharacter {
use SourceCharacter::*;
match values[0].get() {
1..=7 | 121..=127 => Encoder1,
57..=71 => Encoder2,
// The remaining values are supported but not so typical for encoders because they only
// happen at high accelerations.
_ => RangeElement,
}
}
#[cfg(test)]
mod tests {
use super::*;
mod scanning {
use super::*;
use helgoboss_midi::test_util::{channel, control_change, nrpn_14_bit, u14};
use helgoboss_midi::{ParameterNumberMessage, ParameterNumberMessageScanner};
#[test]
fn scan_nrpn() {
// Given
let mut source_scanner = MidiScanner::default();
let mut nrpn_scanner = ParameterNumberMessageScanner::new();
// When
use MidiSourceValue::{ParameterNumber, Plain};
// Message 1
let msg_1 = control_change(1, 99, 0);
let nrpn_1 = nrpn_scanner.feed(&msg_1);
assert_eq!(nrpn_1, None);
let source_1 = source_scanner.feed(Plain(msg_1), None);
// Message 2
let msg_2 = control_change(1, 98, 99);
let nrpn_2 = nrpn_scanner.feed(&msg_2);
let source_2 = source_scanner.feed(Plain(msg_1), None);
assert_eq!(nrpn_2, None);
// Message 3
let msg_3 = control_change(1, 38, 3);
let nrpn_3 = nrpn_scanner.feed(&msg_3);
assert_eq!(nrpn_3, None);
let source_3 = source_scanner.feed(Plain(msg_3), None);
// Message 4
let msg_4 = control_change(1, 6, 2);
let nrpn_4 = nrpn_scanner.feed(&msg_4).unwrap();
assert_eq!(
nrpn_4,
ParameterNumberMessage::non_registered_14_bit(channel(1), u14(99), u14(259))
);
let source_4_nrpn = source_scanner.feed(ParameterNumber(nrpn_4), None);
let source_4_short = source_scanner.feed(Plain(msg_4), None);
// Then
// Even our source scanner is already waiting for more CC messages with the same number,
// a suddenly arriving (N)RPN message should take precedence! Because our real-time
// processor constantly scans for (N)RPN, it would detect at some point that this looks
// like a valid (N)RPN message. This needs to happen *before* the 250 millis
// MAX_CC_WAITING_TIME have expired. In practice this is always the case because there
// should never be much delay between the single messages making up one (N)RPN message.
assert_eq!(source_1, None);
assert_eq!(source_2, None);
assert_eq!(source_3, None);
assert_eq!(
source_4_nrpn.unwrap(),
MidiScanResult {
value: MidiSourceValue::ParameterNumber(nrpn_14_bit(1, 99, 259)),
dev_id: None,
character: None
}
);
assert_eq!(source_4_short, None);
}
}
mod source_character_guessing {
use super::*;
use helgoboss_midi::test_util::u7;
use SourceCharacter::*;
#[test]
fn typical_range() {
assert_eq!(guess(&[40, 41, 42, 43, 44]), RangeElement);
}
#[test]
fn typical_range_counter_clockwise() {
assert_eq!(guess(&[44, 43, 42, 41, 40]), RangeElement);
}
#[test]
fn typical_trigger_button() {
assert_eq!(guess(&[100]), MomentaryButton);
assert_eq!(guess(&[127]), MomentaryButton);
}
#[test]
fn typical_switch_button() {
assert_eq!(guess(&[100, 0]), MomentaryButton);
assert_eq!(guess(&[127, 0]), MomentaryButton);
}
#[test]
fn typical_encoder_1() {
assert_eq!(guess(&[1, 1, 1, 1, 1]), Encoder1);
}
#[test]
fn typical_encoder_2() {
assert_eq!(guess(&[65, 65, 65, 65, 65]), Encoder2);
}
#[test]
fn typical_encoder_2_counter_clockwise() {
assert_eq!(guess(&[63, 63, 63, 63, 63]), Encoder2);
}
#[test]
fn velocity_sensitive_trigger_button() {
assert_eq!(guess(&[79]), MomentaryButton);
assert_eq!(guess(&[10]), MomentaryButton);
}
#[test]
fn velocity_sensitive_switch_button() {
assert_eq!(guess(&[79, 0]), MomentaryButton);
assert_eq!(guess(&[10, 0]), MomentaryButton);
}
#[test]
fn range_with_gaps() {
assert_eq!(guess(&[40, 42, 43, 46]), RangeElement);
}
#[test]
fn range_with_gaps_counter_clockwise() {
assert_eq!(guess(&[44, 41, 40, 37, 35]), RangeElement);
}
#[test]
fn very_lower_range() {
assert_eq!(guess(&[0, 1, 2, 3]), RangeElement);
}
#[test]
fn lower_range() {
assert_eq!(guess(&[1, 2, 3, 4]), RangeElement);
}
#[test]
fn very_upper_range_counter_clockwise() {
assert_eq!(guess(&[127, 126, 125, 124]), RangeElement);
}
#[test]
fn upper_range_counter_clockwise() {
assert_eq!(guess(&[126, 125, 124, 123]), RangeElement);
}
#[test]
fn encoder_1_with_acceleration() {
assert_eq!(guess(&[1, 2, 2, 1, 1]), Encoder1);
}
#[test]
fn encoder_1_with_acceleration_counter_clockwise() {
assert_eq!(guess(&[127, 126, 126, 127, 127]), Encoder1);
}
#[test]
fn encoder_1_with_more_acceleration() {
assert_eq!(guess(&[1, 2, 5, 5, 2]), Encoder1);
}
#[test]
fn encoder_1_with_more_acceleration_counter_clockwise() {
assert_eq!(guess(&[127, 126, 122, 122, 126]), Encoder1);
}
#[test]
fn encoder_2_with_acceleration() {
assert_eq!(guess(&[65, 66, 66, 65, 65]), Encoder2);
}
#[test]
fn encoder_2_with_acceleration_counter_clockwise() {
assert_eq!(guess(&[63, 62, 62, 63, 63]), Encoder2);
}
#[test]
fn encoder_2_with_more_acceleration() {
assert_eq!(guess(&[65, 66, 68, 68, 66]), Encoder2);
}
#[test]
fn encoder_2_with_more_acceleration_counter_clockwise() {
assert_eq!(guess(&[63, 62, 59, 59, 62]), Encoder2);
}
#[test]
fn absolute_encoder_hitting_upper_boundary() {
assert_eq!(guess(&[127, 127, 127, 127, 127]), RangeElement);
assert_eq!(guess(&[125, 126, 127, 127, 127]), RangeElement);
}
#[test]
fn absolute_encoder_hitting_lower_boundary_counter_clockwise() {
assert_eq!(guess(&[0, 0, 0, 0, 0]), RangeElement);
assert_eq!(guess(&[2, 1, 0, 0, 0]), RangeElement);
}
#[test]
fn lower_range_with_duplicate_elements() {
assert_eq!(guess(&[0, 0, 1, 1, 2, 2]), RangeElement);
}
#[test]
fn lower_range_with_duplicate_elements_counter_clockwise() {
assert_eq!(guess(&[2, 2, 1, 1, 0, 0]), RangeElement);
}
#[test]
fn neutral_zone_range_with_duplicate_elements() {
assert_eq!(guess(&[37, 37, 37, 38, 38, 38, 39, 39]), RangeElement);
}
#[test]
fn neutral_zone_range_with_duplicate_elements_counter_clockwise() {
assert_eq!(guess(&[100, 100, 100, 99, 99, 99, 98, 98]), RangeElement);
}
fn guess(values: &[u8]) -> SourceCharacter {
let u7_values: Vec<_> = values.iter().map(|v| u7(*v)).collect();
guess_custom_character(&u7_values)
}
}
}
@@ -0,0 +1,71 @@
use helgoboss_learn::RawMidiEvent;
use reaper_common_types::Hz;
use reaper_medium::MidiInputDeviceId;
#[derive(Debug)]
pub struct MidiTransformationContainer {
/// Emptied right after reading the input buffer of a device.
same_device_events: Vec<RawMidiEvent>,
/// Emptied later, after reading the input buffers of **all** devices (should have a larger capacity).
other_device_events: Vec<DevQualifiedRawMidiEvent>,
/// Always updated with the current device sample rate.
current_device_sample_rate: Hz,
}
#[derive(Debug)]
pub struct DevQualifiedRawMidiEvent {
pub input_device_id: MidiInputDeviceId,
pub event: RawMidiEvent,
}
impl DevQualifiedRawMidiEvent {
fn new(input_device_id: MidiInputDeviceId, event: RawMidiEvent) -> Self {
Self {
input_device_id,
event,
}
}
}
impl Default for MidiTransformationContainer {
fn default() -> Self {
Self::new()
}
}
impl MidiTransformationContainer {
pub fn new() -> Self {
Self {
same_device_events: Vec::with_capacity(100),
other_device_events: Vec::with_capacity(900),
current_device_sample_rate: Hz::default(),
}
}
pub fn prepare(&mut self, device_sample_rate: Hz) {
self.current_device_sample_rate = device_sample_rate;
}
pub fn current_device_sample_rate(&self) -> Hz {
self.current_device_sample_rate
}
pub fn push(&mut self, device: Option<MidiInputDeviceId>, event: RawMidiEvent) {
if let Some(dev) = device {
self.other_device_events
.push(DevQualifiedRawMidiEvent::new(dev, event));
} else {
self.same_device_events.push(event);
}
}
pub fn drain_same_device_events(&mut self) -> impl Iterator<Item = RawMidiEvent> + '_ {
self.same_device_events.drain(..)
}
pub fn drain_other_device_events(
&mut self,
) -> impl Iterator<Item = DevQualifiedRawMidiEvent> + '_ {
self.other_device_events.drain(..)
}
}
@@ -0,0 +1,56 @@
use reaper_common_types::DurationInSeconds;
use reaper_medium::{Hz, MIDI_INPUT_FRAME_RATE};
#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)]
pub struct MidiEvent<T> {
offset: SampleOffset,
payload: T,
}
impl<T: Copy> MidiEvent<T> {
pub fn without_offset(msg: T) -> Self {
Self::new(SampleOffset::ZERO, msg)
}
pub fn new(offset: SampleOffset, payload: T) -> Self {
Self { offset, payload }
}
pub fn offset(&self) -> SampleOffset {
self.offset
}
pub fn payload(&self) -> T {
self.payload
}
}
#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)]
pub struct SampleOffset(u64);
impl SampleOffset {
pub const ZERO: SampleOffset = SampleOffset(0);
pub fn from_midi_input_frame_offset(frame_offset: u32, sample_rate: Hz) -> Self {
let offset_in_secs = frame_offset as f64 / MIDI_INPUT_FRAME_RATE.get();
let offset_in_samples = (offset_in_secs * sample_rate.get()).round() as u64;
SampleOffset(offset_in_samples)
}
pub fn to_midi_input_frame_offset(self, sample_rate: Hz) -> u32 {
let offset_in_secs = self.0 as f64 / sample_rate.get();
(offset_in_secs * MIDI_INPUT_FRAME_RATE.get()).round() as u32
}
pub fn new(value: u64) -> Self {
SampleOffset(value)
}
pub fn get(self) -> u64 {
self.0
}
pub fn to_seconds(self, sample_rate: Hz) -> DurationInSeconds {
DurationInSeconds::new_panic(self.0 as f64 / sample_rate.get())
}
}
@@ -0,0 +1,43 @@
use crate::domain::IncomingMidiMessage;
use helgoboss_midi::{ShortMessage, ShortMessageType};
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
pub enum MidiMessageClassification {
Normal,
Ignored,
Timing,
}
pub fn classify_midi_message(msg: IncomingMidiMessage) -> MidiMessageClassification {
match msg {
IncomingMidiMessage::SysEx(_) => MidiMessageClassification::Normal,
IncomingMidiMessage::Short(msg) => {
use ShortMessageType::*;
match msg.r#type() {
NoteOff
| NoteOn
| PolyphonicKeyPressure
| ControlChange
| ProgramChange
| ChannelPressure
| PitchBendChange
| Start
| Continue
| Stop => MidiMessageClassification::Normal,
SystemExclusiveStart
| TimeCodeQuarterFrame
| SongPositionPointer
| SongSelect
| SystemCommonUndefined1
| SystemCommonUndefined2
| TuneRequest
| SystemExclusiveEnd
| SystemRealTimeUndefined1
| SystemRealTimeUndefined2
| ActiveSensing
| SystemReset => MidiMessageClassification::Ignored,
TimingClock => MidiMessageClassification::Timing,
}
}
}
}
@@ -0,0 +1,182 @@
mod real_time_processor;
pub use real_time_processor::*;
mod main_processor;
pub use main_processor::*;
mod mapping;
pub use mapping::*;
mod control_surface;
pub use control_surface::*;
mod feedback_collector;
pub use feedback_collector::*;
mod audio_hook;
pub use audio_hook::*;
mod mode;
pub use mode::*;
mod source;
pub use source::*;
mod midi_source;
pub use midi_source::*;
mod eel_transformation;
pub use eel_transformation::*;
mod eel_midi_source_script;
pub use eel_midi_source_script::*;
mod lua_script_commons;
pub use lua_script_commons::*;
mod lua_midi_source_script;
pub use lua_midi_source_script::*;
mod lua_feedback_script;
pub use lua_feedback_script::*;
mod flexible_midi_source_script;
pub use flexible_midi_source_script::*;
mod realearn_target;
pub use realearn_target::*;
mod reaper_target;
pub use reaper_target::*;
mod unresolved_reaper_target;
pub use unresolved_reaper_target::*;
mod processor_context;
pub use processor_context::*;
mod r#virtual;
pub use r#virtual::*;
mod midi_util;
pub use midi_util::*;
mod midi_source_scanner;
pub use midi_source_scanner::*;
mod midi_transformation_container;
pub use midi_transformation_container::*;
mod midi_clock_calculator;
pub use midi_clock_calculator::*;
mod conditional_activation;
pub use conditional_activation::*;
mod eventing;
pub use eventing::*;
pub mod ui_util;
mod realearn_target_context;
pub use realearn_target_context::*;
mod realearn_source_context;
pub use realearn_source_context::*;
mod backbone;
pub use backbone::*;
mod unit;
pub use unit::*;
mod osc;
pub use osc::*;
mod exclusivity;
pub use exclusivity::*;
mod io;
pub use io::*;
mod targets;
pub use targets::*;
mod group;
pub use group::*;
mod midi_types;
pub use midi_types::*;
mod reaper_source;
pub use reaper_source::*;
mod key_source;
pub use key_source::*;
mod stream_deck_device;
pub use stream_deck_device::*;
mod stream_deck_source;
pub use stream_deck_source::*;
mod device_change_detector;
pub use device_change_detector::*;
mod reaper_config_change_detector;
pub use reaper_config_change_detector::*;
mod monitoring_fx_chain_change_detector;
pub use monitoring_fx_chain_change_detector::*;
mod tag;
pub use tag::*;
mod mapping_snapshot;
pub use mapping_snapshot::*;
mod organization;
pub use organization::*;
mod props;
pub use props::*;
mod accelerator;
pub use accelerator::*;
mod parameter;
pub use parameter::*;
mod parameter_manager;
pub use parameter_manager::*;
mod control_event;
pub use control_event::*;
mod lua_support;
pub use lua_support::*;
mod lua_module_container;
pub use lua_module_container::*;
mod internal_info_event;
pub use internal_info_event::*;
mod instance;
pub use instance::*;
mod real_time_instance;
pub use real_time_instance::*;
mod midi_dev_management;
pub use midi_dev_management::*;
#[cfg(feature = "playtime")]
mod playtime_util;
mod hex;
pub use hex::*;
mod global_audio_state;
pub use global_audio_state::*;
@@ -0,0 +1,39 @@
use crate::base::CloneAsDefault;
use crate::domain::{
AdditionalLuaFeedbackScriptInput, ControlEventTimestamp, EelTransformation, LuaFeedbackScript,
};
use base::hash_util::NonCryptoHashSet;
use helgoboss_learn::{FeedbackScript, FeedbackScriptInput, FeedbackScriptOutput, ModeContext};
use std::borrow::Cow;
use std::error::Error;
pub type RealearnModeContext<'a> = ModeContext<AdditionalLuaFeedbackScriptInput<'a>>;
/// See [`crate::domain::MidiSource`] for an explanation of the feedback script wrapping.
type FeedbackScriptType = CloneAsDefault<Option<LuaFeedbackScript<'static>>>;
pub type Mode = helgoboss_learn::Mode<EelTransformation, FeedbackScriptType, ControlEventTimestamp>;
impl FeedbackScriptType {
fn get_script(&self) -> Result<&LuaFeedbackScript<'static>, Cow<'static, str>> {
self.get()
.as_ref()
.ok_or(Cow::Borrowed("script was removed on clone"))
}
}
impl<'a> FeedbackScript<'a> for FeedbackScriptType {
type AdditionalInput = AdditionalLuaFeedbackScriptInput<'a>;
fn feedback(
&self,
input: FeedbackScriptInput,
additional_input: Self::AdditionalInput,
) -> Result<FeedbackScriptOutput, Cow<'static, str>> {
self.get_script()?.feedback(input, additional_input)
}
fn used_props(&self) -> Result<NonCryptoHashSet<String>, Box<dyn Error>> {
self.get_script()?.used_props()
}
}
@@ -0,0 +1,143 @@
use base::hash_util::NonCryptoHashMap;
use either::Either;
use reaper_high::{
ChangeEvent, Fx, FxAddedEvent, FxClosedEvent, FxEnabledChangedEvent, FxOpenedEvent,
FxRemovedEvent, FxReorderedEvent, Guid, Reaper,
};
use std::iter;
/// It's a known fact that REAPER doesn't inform about changes on the monitoring FX chain via
/// control surface callback methods.
///
/// There are various ways we handle this:
///
/// - **FX parameter value changes:** By polling (only mapped parameters though, because iterating
/// over all parameters on each main loop cycle could be very resource-consuming).
/// - **FX focused:** Seems to be detected correctly already.
/// - **FX on/off, FX added/removed/reordered, FX closed/open:** We do that here by polling all
/// monitoring FX instances on each main loop cycle.
/// - **FX preset changed:** We don't do that because it takes quite a long time compared to all the
/// other checks (checked with REAPER 6.56).
#[derive(Debug, Default)]
pub struct MonitoringFxChainChangeDetector {
items: NonCryptoHashMap<Guid, Item>,
}
#[derive(Debug)]
struct Item {
fx: Fx,
index: u32,
enabled: bool,
open: bool,
}
impl MonitoringFxChainChangeDetector {
pub fn poll_for_changes(&mut self) -> Vec<ChangeEvent> {
let new_items = gather_monitoring_fxs();
let change_events = diff(&self.items, &new_items);
self.items = new_items;
change_events
}
}
fn diff(
previous_items: &NonCryptoHashMap<Guid, Item>,
next_items: &NonCryptoHashMap<Guid, Item>,
) -> Vec<ChangeEvent> {
// Removed
let mut at_least_one_removed = false;
let removed = previous_items.iter().filter_map(|(prev_guid, prev_item)| {
if next_items.contains_key(prev_guid) {
None
} else {
at_least_one_removed = true;
let event = FxRemovedEvent {
fx: prev_item.fx.clone(),
};
Some(ChangeEvent::FxRemoved(event))
}
});
// Added
let mut at_least_one_added = false;
let added = next_items.iter().filter_map(|(next_guid, next_item)| {
if previous_items.contains_key(next_guid) {
None
} else {
at_least_one_added = true;
let event = FxAddedEvent {
fx: next_item.fx.clone(),
};
Some(ChangeEvent::FxAdded(event))
}
});
// Reordered
let is_reordered = previous_items.iter().any(|(prev_guid, prev_item)| {
if let Some(next_item) = next_items.get(prev_guid) {
next_item.index != prev_item.index
} else {
false
}
});
let reordered = if is_reordered {
let event = FxReorderedEvent {
track: Reaper::get()
.current_project()
.master_track()
.expect("master track of current project must exist"),
};
Either::Left(iter::once(ChangeEvent::FxReordered(event)))
} else {
Either::Right(iter::empty())
};
// Changed
let changed = previous_items.iter().flat_map(|(prev_guid, prev_item)| {
if let Some(next_item) = next_items.get(prev_guid) {
let opened_closed = if next_item.open != prev_item.open {
let fx = next_item.fx.clone();
let event = if next_item.open {
ChangeEvent::FxOpened(FxOpenedEvent { fx })
} else {
ChangeEvent::FxClosed(FxClosedEvent { fx })
};
Some(event)
} else {
None
};
let enabled = if next_item.enabled != prev_item.enabled {
Some(ChangeEvent::FxEnabledChanged(FxEnabledChangedEvent {
fx: next_item.fx.clone(),
new_value: next_item.enabled,
}))
} else {
None
};
Either::Left(opened_closed.into_iter().chain(enabled))
} else {
Either::Right(iter::empty())
}
});
// Combined
removed
.chain(added)
.chain(reordered)
.chain(changed)
.collect()
}
fn gather_monitoring_fxs() -> NonCryptoHashMap<Guid, Item> {
Reaper::get()
.monitoring_fx_chain()
.fxs()
.enumerate()
.map(|(i, fx)| {
let key = fx.guid().expect("monitoring FX no GUID");
let value = Item {
index: i as u32,
enabled: fx.is_enabled(),
open: fx.window_is_open(),
fx,
};
(key, value)
})
.collect()
}
@@ -0,0 +1,68 @@
use crate::domain::{Exclusivity, Tag};
use base::hash_util::NonCryptoHashSet;
use std::hash::Hash;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TagScope {
pub tags: NonCryptoHashSet<Tag>,
}
impl TagScope {
pub fn determine_enable_disable_change(
&self,
exclusivity: Exclusivity,
tags: &[Tag],
is_enable: bool,
) -> Option<bool> {
use Exclusivity::*;
if exclusivity == Exclusive || (exclusivity == ExclusiveOnOnly && is_enable) {
// Exclusive
if self.has_tags() {
// Set mappings that match the scope tags and unset all others as long as they
// have tags!
if tags.is_empty() {
None
} else {
Some(has_any_of(&self.tags, tags))
}
} else {
// Scope doesn't define any tags. Unset *all* mappings as long as
// they have tags.
if tags.is_empty() {
None
} else {
Some(false)
}
}
} else {
// Non-exclusive
if !self.has_tags() || has_any_of(&self.tags, tags) {
// Non-exclusive, so we just add to or remove from mappings that are
// currently active (= relative).
Some(true)
} else {
// Don't touch mappings that don't match the tags.
None
}
}
}
pub fn any_tag_matches(&self, other_tags: &[Tag]) -> bool {
has_any_of(&self.tags, other_tags)
}
pub fn has_tags(&self) -> bool {
!self.tags.is_empty()
}
pub fn overlaps_with(&self, tag_scope: &TagScope) -> bool {
has_any_of(&self.tags, &tag_scope.tags)
}
}
fn has_any_of<'a, T: 'a + Eq + Hash>(
self_tags: &NonCryptoHashSet<T>,
other_tags: impl IntoIterator<Item = &'a T>,
) -> bool {
other_tags.into_iter().any(|t| self_tags.contains(t))
}
@@ -0,0 +1,304 @@
use crossbeam_channel::Receiver;
use derive_more::Display;
use rosc::{OscBundle, OscMessage, OscPacket};
use serde::{Deserialize, Serialize};
use std::error::Error;
use std::io;
use std::net::{SocketAddrV4, UdpSocket};
use anyhow::Context;
use core::mem;
use std::str::FromStr;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread::JoinHandle;
use std::time::Duration;
use tracing::{trace, warn};
use uuid::Uuid;
const MAX_INCOMING_PACKET_SIZE: usize = 10_000;
const OSC_OUTGOING_BULK_SIZE: usize = 16;
pub struct OscFeedbackTask {
dev_id: OscDeviceId,
msg: OscMessage,
}
impl OscFeedbackTask {
pub fn new(dev_id: OscDeviceId, msg: OscMessage) -> Self {
Self { dev_id, msg }
}
}
#[derive(Debug)]
pub struct OscFeedbackProcessor {
state: State,
}
#[derive(Debug)]
enum State {
Stopped(StoppedState),
Starting,
Running(RunningState),
Stopping,
}
#[derive(Debug)]
struct StoppedState {
task_receiver: Receiver<OscFeedbackTask>,
}
#[derive(Debug)]
struct RunningState {
request_stop: Arc<AtomicBool>,
join_handle: JoinHandle<OscFeedbackHandler>,
}
impl OscFeedbackProcessor {
pub fn new(task_receiver: Receiver<OscFeedbackTask>) -> Self {
Self {
state: State::Stopped(StoppedState { task_receiver }),
}
}
pub fn start(&mut self, osc_output_devices: Vec<OscOutputDevice>) {
if osc_output_devices.is_empty() || !matches!(&self.state, State::Stopped(_)) {
return;
}
let state = if let State::Stopped(s) = mem::replace(&mut self.state, State::Starting) {
s
} else {
panic!("manager was not stopped");
};
let mut handler = OscFeedbackHandler {
task_receiver: state.task_receiver,
osc_output_devices,
};
let request_stop = Arc::new(AtomicBool::new(false));
let request_stop_clone = request_stop.clone();
let join_handle = std::thread::Builder::new()
.name("ReaLearn OSC sender".to_owned())
.spawn(move || {
while !request_stop_clone.load(Ordering::SeqCst) {
handler.cycle();
}
handler
})
.unwrap();
self.state = State::Running(RunningState {
request_stop,
join_handle,
});
}
pub fn stop(&mut self) {
if !matches!(&self.state, State::Running(_)) {
return;
}
let state = if let State::Running(s) = mem::replace(&mut self.state, State::Stopping) {
s
} else {
panic!("manager was not started");
};
state.request_stop.store(true, Ordering::SeqCst);
let handler = state.join_handle.join().unwrap();
self.state = State::Stopped(StoppedState {
task_receiver: handler.return_task_receiver(),
});
}
}
struct OscFeedbackHandler {
task_receiver: Receiver<OscFeedbackTask>,
osc_output_devices: Vec<OscOutputDevice>,
}
impl OscFeedbackHandler {
pub fn cycle(&mut self) {
use itertools::Itertools;
let grouped_by_device = self
.task_receiver
.try_iter()
.take(OSC_OUTGOING_BULK_SIZE)
.sorted_by_key(|task| task.dev_id)
.group_by(|task| task.dev_id);
for (dev_id, group) in grouped_by_device.into_iter() {
if let Some(dev) = self.osc_output_devices.iter().find(|d| d.id() == dev_id) {
let _ = dev.send(group.map(|task| task.msg));
}
}
std::thread::sleep(Duration::from_millis(1));
}
pub fn return_task_receiver(self) -> Receiver<OscFeedbackTask> {
self.task_receiver
}
}
#[derive(Debug)]
pub struct OscInputDevice {
id: OscDeviceId,
socket: UdpSocket,
osc_buffer: [u8; MAX_INCOMING_PACKET_SIZE],
}
impl OscInputDevice {
pub fn bind(id: OscDeviceId, socket: UdpSocket) -> Result<OscInputDevice, Box<dyn Error>> {
let dev = OscInputDevice {
id,
socket,
osc_buffer: [0; MAX_INCOMING_PACKET_SIZE],
};
Ok(dev)
}
pub fn id(&self) -> &OscDeviceId {
&self.id
}
pub fn poll(&mut self) -> Result<Option<OscPacket>, &'static str> {
match self.socket.recv(&mut self.osc_buffer) {
Ok(num_bytes) => match rosc::decoder::decode_udp(&self.osc_buffer[..num_bytes]) {
Ok((_, packet)) => {
trace!("Received packet with {} bytes: {:#?}", num_bytes, &packet);
Ok(Some(packet))
}
Err(err) => {
warn!("Error trying to decode OSC packet: {:?}", err);
Err("error trying to decode OSC messages")
}
},
Err(ref err) if err.kind() != io::ErrorKind::WouldBlock => {
warn!("Error trying to receive OSC packet: {}", err);
Err("error trying to receive OSC message")
}
// We don't need to handle "would block" because we are running in a loop anyway.
_ => Ok(None),
}
}
pub fn poll_multiple(&mut self, n: usize) -> impl Iterator<Item = OscPacket> + '_ {
(0..n).flat_map(move |_| self.poll().ok().flatten())
}
}
#[derive(Debug)]
pub struct OscOutputDevice {
id: OscDeviceId,
socket: UdpSocket,
dest_address: SocketAddrV4,
can_deal_with_bundles: bool,
}
impl OscOutputDevice {
pub fn new(
id: OscDeviceId,
socket: UdpSocket,
dest_address: SocketAddrV4,
can_deal_with_bundles: bool,
) -> Self {
// Attention: It's important that we don't use `UdpSocket::connect` here as this breaks
// control. No idea why exactly, but it must have something to do with the fact that we
// clone the control socket (in order to support "respond to sending port" scenario).
// See https://github.com/helgoboss/helgobox/issues/706 and
// https://github.com/helgoboss/helgobox/issues/551.
OscOutputDevice {
id,
socket,
dest_address,
can_deal_with_bundles,
}
}
pub fn id(&self) -> OscDeviceId {
self.id
}
pub fn send(&self, messages: impl Iterator<Item = OscMessage>) -> Result<(), &'static str> {
if self.can_deal_with_bundles {
// Haven't realized a performance difference between sending a bundle or single
// messages. However, REAPER sends a bundle (maybe in order to use time tags).
// Let's do it, too, if the device supports it.
self.send_as_bundle(messages)
} else {
self.send_as_messages(messages)
}
}
fn send_as_bundle(
&self,
messages: impl Iterator<Item = OscMessage>,
) -> Result<(), &'static str> {
let bundle = OscBundle {
// That should be "immediately" according to the OSC Time Tag spec.
timetag: (0, 1).into(),
content: messages.map(OscPacket::Message).collect(),
};
let packet = OscPacket::Bundle(bundle);
let bytes = rosc::encoder::encode(&packet)
.map_err(|_| "error trying to encode OSC bundle packet")?;
trace!(
"Sending bundle packet with {} bytes: {:#?}",
bytes.len(),
&packet
);
self.socket
.send_to(&bytes, self.dest_address)
.map_err(|_| "error trying to send OSC bundle packet")?;
Ok(())
}
fn send_as_messages(
&self,
messages: impl Iterator<Item = OscMessage>,
) -> Result<(), &'static str> {
for m in messages {
let packet = OscPacket::Message(m);
let bytes = rosc::encoder::encode(&packet)
.map_err(|_| "error trying to encode OSC message packet")?;
trace!(
"Sending message packet with {} bytes: {:#?}",
bytes.len(),
&packet
);
self.socket
.send_to(&bytes, self.dest_address)
.map_err(|_| "error trying to send OSC message packet")?;
}
Ok(())
}
}
/// An OSC device ID.
///
/// This uniquely identifies an OSC device according to ReaLearn's device configuration.
#[derive(
Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Display, Serialize, Deserialize,
)]
#[serde(transparent)]
pub struct OscDeviceId(uuid::Uuid);
impl OscDeviceId {
pub fn random() -> OscDeviceId {
OscDeviceId(Uuid::new_v4())
}
pub fn fmt_short(&self) -> String {
self.0.to_string().chars().take(5).collect()
}
}
impl FromStr for OscDeviceId {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(OscDeviceId(s.parse().context("invalid OSC device ID")?))
}
}
#[derive(Clone, PartialEq, Debug)]
pub struct OscScanResult {
pub message: OscMessage,
pub dev_id: Option<OscDeviceId>,
}
@@ -0,0 +1,414 @@
use crate::domain::CompartmentKind;
use base::default_util::{deserialize_null_default, is_default};
use derive_more::Display;
use enum_map::EnumMap;
use helgoboss_learn::UnitValue;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::fmt;
use std::fmt::{Display, Formatter};
use std::num::NonZeroU32;
use std::ops::{Add, RangeInclusive};
/// Total number of parameters of the plug-in.
pub const PLUGIN_PARAMETER_COUNT: u32 = 200;
/// Number of parameters per compartment.
pub const COMPARTMENT_PARAMETER_COUNT: u32 = 100;
/// Returns an iterator over the range of compartment parameter indices.
pub fn compartment_param_index_iter() -> impl Iterator<Item = CompartmentParamIndex> {
convert_compartment_param_index_range_to_iter(&compartment_param_index_range())
}
/// Returns the range of compartment parameter indices.
pub fn compartment_param_index_range() -> RangeInclusive<CompartmentParamIndex> {
CompartmentParamIndex(0)..=CompartmentParamIndex(COMPARTMENT_PARAMETER_COUNT - 1)
}
/// We need this because the `step_trait` is not stabilized yet.
pub fn convert_plugin_param_index_range_to_iter(
range: &RangeInclusive<PluginParamIndex>,
) -> impl Iterator<Item = PluginParamIndex> {
(range.start().get()..=range.end().get()).map(PluginParamIndex)
}
/// We need this because the `step_trait` is not stabilized yet.
pub fn convert_compartment_param_index_range_to_iter(
range: &RangeInclusive<CompartmentParamIndex>,
) -> impl Iterator<Item = CompartmentParamIndex> {
(range.start().get()..=range.end().get()).map(CompartmentParamIndex)
}
/// Raw parameter value.
pub type RawParamValue = f32;
/// Effective parameter value.
#[derive(Copy, Clone, PartialEq, Debug)]
pub enum EffectiveParamValue {
Continuous(f64),
Discrete(u32),
}
impl Display for EffectiveParamValue {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self {
EffectiveParamValue::Continuous(v) => write!(f, "{:.3}", *v),
EffectiveParamValue::Discrete(v) => v.fmt(f),
}
}
}
impl From<EffectiveParamValue> for f64 {
fn from(v: EffectiveParamValue) -> Self {
match v {
EffectiveParamValue::Continuous(v) => v,
EffectiveParamValue::Discrete(v) => v as f64,
}
}
}
/// Parameter setting.
#[derive(Clone, Debug, Eq, PartialEq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ParamSetting {
#[serde(
default,
deserialize_with = "deserialize_null_default",
skip_serializing_if = "is_default"
)]
pub key: Option<String>,
#[serde(
default,
deserialize_with = "deserialize_null_default",
skip_serializing_if = "is_default"
)]
pub name: String,
#[serde(
default,
deserialize_with = "deserialize_null_default",
skip_serializing_if = "is_default"
)]
pub value_count: Option<NonZeroU32>,
#[serde(
default,
deserialize_with = "deserialize_null_default",
skip_serializing_if = "is_default"
)]
pub value_labels: Vec<String>,
}
impl ParamSetting {
fn is_default(&self) -> bool {
self.key.is_none() && self.name.is_empty() && self.value_count.is_none()
}
pub fn discrete_values(&self) -> Option<impl Iterator<Item = Cow<str>> + '_> {
let value_count = self.value_count?;
let iter = (0..value_count.get()).map(|v| {
self.find_label_for_value(v)
.map(|v| v.into())
.unwrap_or_else(|| v.to_string().into())
});
Some(iter)
}
/// Checks if the given key matches the key of this parameter (if a key is defined).
pub fn key_matches(&self, key: &str) -> bool {
if let Some(k) = self.key.as_ref() {
k == key
} else {
false
}
}
pub fn with_raw_value(&self, value: RawParamValue) -> impl Display + '_ {
SettingAndValue {
setting: self,
value,
}
}
pub fn convert_to_value(&self, raw_value: RawParamValue) -> EffectiveParamValue {
let raw_value = UnitValue::new_clamped(raw_value as _);
if let Some(value_count) = self.value_count {
let scaled = raw_value.get() * (value_count.get() - 1) as f64;
EffectiveParamValue::Discrete(scaled.round() as u32)
} else {
EffectiveParamValue::Continuous(raw_value.get())
}
}
fn convert_to_raw_value(&self, effective_value: f64) -> RawParamValue {
let raw_value = if let Some(value_count) = self.value_count {
effective_value / (value_count.get() - 1) as f64
} else {
effective_value
};
UnitValue::new_clamped(raw_value).get() as RawParamValue
}
pub fn find_label_for_value(&self, value: u32) -> Option<&str> {
self.value_labels.get(value as usize).map(|s| s.as_str())
}
/// Attempts to parse the given text to a raw parameter value.
pub fn parse_to_raw_value(&self, text: &str) -> Result<RawParamValue, &'static str> {
let effective_value: f64 = text.parse().map_err(|_| "couldn't parse as number")?;
Ok(self.convert_to_raw_value(effective_value))
}
}
struct SettingAndValue<'a> {
setting: &'a ParamSetting,
value: RawParamValue,
}
impl Display for SettingAndValue<'_> {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
let effective_value = self.setting.convert_to_value(self.value);
if let EffectiveParamValue::Discrete(v) = effective_value {
if let Some(label) = self.setting.find_label_for_value(v) {
return label.fmt(f);
}
}
effective_value.fmt(f)
}
}
/// Setting and value combined.
#[derive(Clone, Debug, Default)]
pub struct Param {
setting: ParamSetting,
value: RawParamValue,
}
impl Param {
/// Creates a new parameter.
pub fn new(setting: ParamSetting, value: RawParamValue) -> Self {
Self { setting, value }
}
/// Returns the effective parameter value (taking the parameter setting into account).
pub fn effective_value(&self) -> EffectiveParamValue {
self.setting.convert_to_value(self.value)
}
/// Returns the setting of this parameter.
pub fn setting(&self) -> &ParamSetting {
&self.setting
}
/// Sets the setting of this parameter.
pub fn set_setting(&mut self, setting: ParamSetting) {
self.setting = setting;
}
/// Returns the raw value of this parameter.
pub fn raw_value(&self) -> RawParamValue {
self.value
}
/// Sets the raw value of this parameter.
pub fn set_raw_value(&mut self, value: RawParamValue) {
self.value = value;
}
}
impl Display for Param {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
let setting_and_value = SettingAndValue {
setting: &self.setting,
value: self.value,
};
setting_and_value.fmt(f)
}
}
/// All parameters for a particular compartment.
#[derive(Clone, Debug)]
pub struct CompartmentParams(Vec<Param>);
impl Default for CompartmentParams {
fn default() -> Self {
let vector = vec![Default::default(); COMPARTMENT_PARAMETER_COUNT as usize];
Self(vector)
}
}
impl CompartmentParams {
/// Returns the parameter at the given index.
pub fn at(&self, index: CompartmentParamIndex) -> &Param {
self.0.get(index.get() as usize).unwrap()
}
/// Returns the parameter at the given index, mutable.
pub fn at_mut(&mut self, index: CompartmentParamIndex) -> &mut Param {
self.0.get_mut(index.get() as usize).unwrap()
}
/// Returns the name of the parameter including its position.
pub fn get_parameter_name(&self, index: CompartmentParamIndex) -> Cow<str> {
let setting = &self.at(index).setting;
if setting.name.is_empty() {
Cow::Owned(format!("Param {}", index.get() + 1))
} else {
Cow::Borrowed(&setting.name)
}
}
/// Returns a map of all parameter settings that don't correspond to the defaults.
pub fn non_default_settings(&self) -> Vec<(CompartmentParamIndex, ParamSetting)> {
self.0
.iter()
.map(|p| &p.setting)
.enumerate()
.filter(|(_, s)| !s.is_default())
.map(|(i, s)| {
(
CompartmentParamIndex::try_from(i as u32).unwrap(),
s.clone(),
)
})
.collect()
}
/// Applies the given settings.
pub fn apply_given_settings(&mut self, settings: Vec<(CompartmentParamIndex, ParamSetting)>) {
for (i, setting) in settings {
self.at_mut(i).setting = setting;
}
}
/// Resets all settings and values to the defaults.
pub fn reset_all(&mut self) {
*self = Default::default();
}
pub fn find_setting_by_key(&self, key: &str) -> Option<(CompartmentParamIndex, &ParamSetting)> {
self.0
.iter()
.enumerate()
.find(|(_, s)| s.setting.key.as_ref().map(|k| k == key).unwrap_or(false))
.map(|(i, s)| {
(
CompartmentParamIndex::try_from(i as u32).unwrap(),
&s.setting,
)
})
}
}
/// All parameters for the complete plug-in.
#[derive(Clone, Debug, Default)]
pub struct PluginParams {
compartment_params: EnumMap<CompartmentKind, CompartmentParams>,
}
impl PluginParams {
/// Returns the parameter at the given index.
pub fn at(&self, index: PluginParamIndex) -> &Param {
let (compartment, index) = CompartmentKind::translate_plugin_param_index(index);
self.compartment_params(compartment).at(index)
}
/// Returns the parameter at the given index, mutable.
pub fn at_mut(&mut self, index: PluginParamIndex) -> &mut Param {
let (compartment, index) = CompartmentKind::translate_plugin_param_index(index);
self.compartment_params_mut(compartment).at_mut(index)
}
/// Returns the parameter for the given compartment.
pub fn compartment_params(&self, compartment: CompartmentKind) -> &CompartmentParams {
&self.compartment_params[compartment]
}
/// Returns the parameter for the given compartment, mutable.
pub fn compartment_params_mut(
&mut self,
compartment: CompartmentKind,
) -> &mut CompartmentParams {
&mut self.compartment_params[compartment]
}
/// Returns the parameter name prefixed with compartment label.
pub fn build_qualified_parameter_name(&self, index: PluginParamIndex) -> String {
let (compartment, index) = CompartmentKind::translate_plugin_param_index(index);
let compartment_param_name = self
.compartment_params(compartment)
.get_parameter_name(index);
let compartment_label = match compartment {
CompartmentKind::Controller => "Ctrl",
CompartmentKind::Main => "Main",
};
format!(
"{} p{}: {}",
compartment_label,
index.get() + 1,
compartment_param_name
)
}
}
/// Refers to a parameter within the complete set of plug-in parameters.
#[derive(Copy, Clone, Eq, PartialEq, PartialOrd, Hash, Debug, Default, Display)]
pub struct PluginParamIndex(u32);
impl PluginParamIndex {
/// Returns the raw index.
pub fn get(&self) -> u32 {
self.0
}
}
impl Add<u32> for PluginParamIndex {
type Output = Option<Self>;
fn add(self, rhs: u32) -> Self::Output {
Self::try_from(self.0 + rhs).ok()
}
}
impl TryFrom<u32> for PluginParamIndex {
type Error = &'static str;
fn try_from(value: u32) -> Result<Self, Self::Error> {
if value >= PLUGIN_PARAMETER_COUNT {
return Err("invalid plug-in parameter index");
}
Ok(Self(value))
}
}
/// Refers to a parameter within one compartment.
#[derive(
Copy, Clone, Eq, PartialEq, PartialOrd, Hash, Debug, Default, Serialize, Deserialize, Display,
)]
#[serde(try_from = "u32")]
pub struct CompartmentParamIndex(u32);
impl CompartmentParamIndex {
/// Returns the raw index.
pub fn get(&self) -> u32 {
self.0
}
}
impl Add<u32> for CompartmentParamIndex {
type Output = Option<Self>;
fn add(self, rhs: u32) -> Self::Output {
Self::try_from(self.0 + rhs).ok()
}
}
impl TryFrom<u32> for CompartmentParamIndex {
type Error = &'static str;
fn try_from(value: u32) -> Result<Self, Self::Error> {
if value >= COMPARTMENT_PARAMETER_COUNT {
return Err("invalid compartment parameter index");
}
Ok(Self(value))
}
}
@@ -0,0 +1,107 @@
use crate::domain::{
CompartmentKind, CompartmentParamIndex, CompartmentParams, ControlEventTimestamp, ParamSetting,
ParameterMainTask, PluginParamIndex, PluginParams, RawParamValue,
};
use base::{blocking_read_lock, blocking_write_lock, NamedChannelSender, SenderToNormalThread};
use reaper_high::Reaper;
use reaper_medium::ProjectRef;
use std::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
#[derive(Debug)]
pub struct ParameterManager {
/// Canonical parameters.
///
/// Locked by a read-write lock because this will be accessed from different threads. At least
/// the values. But we want to keep settings and values tightly together for reasons of
/// simplicity, so we put them into the read-write lock as well.
params: RwLock<PluginParams>,
parameter_main_task_sender: SenderToNormalThread<ParameterMainTask>,
}
impl ParameterManager {
pub fn new(parameter_main_task_sender: SenderToNormalThread<ParameterMainTask>) -> Self {
Self {
params: Default::default(),
parameter_main_task_sender,
}
}
pub fn update_certain_compartment_param_settings(
&self,
compartment: CompartmentKind,
settings: Vec<(CompartmentParamIndex, ParamSetting)>,
) {
let mut plugin_params = self.params_mut();
let compartment_params = plugin_params.compartment_params_mut(compartment);
compartment_params.apply_given_settings(settings);
}
pub fn update_compartment_params(
&self,
compartment: CompartmentKind,
params: CompartmentParams,
) {
let mut plugin_params = self.params_mut();
let compartment_params = plugin_params.compartment_params_mut(compartment);
*compartment_params = params;
// Propagate
// send_if_space because https://github.com/helgoboss/helgobox/issues/847
self.parameter_main_task_sender
.send_if_space(ParameterMainTask::UpdateAllParams(plugin_params.clone()));
}
pub fn set_all_parameters(&self, params: PluginParams) {
let mut plugin_params = self.params_mut();
*plugin_params = params;
// Propagate
// send_if_space because https://github.com/helgoboss/helgobox/issues/847
self.parameter_main_task_sender
.send_if_space(ParameterMainTask::UpdateAllParams(plugin_params.clone()));
}
pub fn set_single_parameter(&self, index: PluginParamIndex, value: RawParamValue) {
let mut params = self.params_mut();
let param = params.at_mut(index);
let current_value = param.raw_value();
if current_value == value {
// No need to update. This can happen a lot if a ReaLearn parameter is being automated.
return;
}
// Update synchronously so that a subsequent `get_parameter` will immediately
// return the new value.
param.set_raw_value(value);
// We immediately send to the main processor. Sending to the session and using the
// session parameter list as single source of truth is no option because this method
// will be called in a processing thread, not in the main thread. Not even a mutex would
// help here because the session is conceived for main-thread usage only! I was not
// aware of this being called in another thread and it led to subtle errors of course
// (https://github.com/helgoboss/helgobox/issues/59).
// When rendering, we don't do it because that will accumulate until the rendering is
// finished, which is pointless.
if !is_rendering() {
let timestamp = ControlEventTimestamp::from_main_thread();
self.parameter_main_task_sender.send_complaining(
ParameterMainTask::UpdateSingleParamValue {
index,
value,
timestamp,
},
);
}
}
pub fn params(&self) -> RwLockReadGuard<PluginParams> {
blocking_read_lock(&self.params, "ParameterManager params")
}
fn params_mut(&self) -> RwLockWriteGuard<PluginParams> {
blocking_write_lock(&self.params, "ParameterManager params_mut")
}
}
fn is_rendering() -> bool {
Reaper::get()
.medium_reaper()
.enum_projects(ProjectRef::CurrentlyRendering, 0)
.is_some()
}
@@ -0,0 +1,66 @@
use crate::domain::{
transport_is_enabled_unit_value, Backbone, CompartmentKind, ExtendedProcessorContext,
TrackResolveError, VirtualPlaytimeColumn,
};
use helgoboss_learn::UnitValue;
use helgobox_api::persistence::ClipColumnTrackContext;
use reaper_high::Track;
/// In clip slot targets, the resolve phase makes sure that the targeted slot actually exists.
/// So if we get a `None` value from some of the clip slot methods, it's because the slot doesn't
/// have a clip, which is a valid state and should return *something*. The contract of the target
/// `current_value()` is that if it returns `None`, it means it can't get a value at the moment,
/// probably just temporarily. In that case, the feedback is simply not updated.
pub fn interpret_current_clip_slot_value<T: Default>(value: Option<T>) -> Option<T> {
Some(value.unwrap_or_default())
}
pub fn resolve_virtual_track_by_playtime_column(
context: ExtendedProcessorContext,
compartment: CompartmentKind,
column: &VirtualPlaytimeColumn,
track_context: &ClipColumnTrackContext,
) -> Result<Vec<Track>, TrackResolveError> {
// TODO-low Not very helpful. Do we even use this error type?
let generic_error = || TrackResolveError::TrackNotFound {
guid: None,
name: None,
index: None,
};
let clip_column_index = column
.resolve(context, compartment)
.map_err(|_| generic_error())?;
let track = Backbone::get()
.with_clip_matrix(context.control_context.instance(), |matrix| {
let column = matrix.get_column(clip_column_index)?;
match track_context {
ClipColumnTrackContext::Playback => column.playback_track().cloned(),
ClipColumnTrackContext::Recording => column.effective_recording_track(),
}
})
.map_err(|_| generic_error())?
.map_err(|_| generic_error())?;
Ok(vec![track])
}
// Panics if called with repeat or record.
#[cfg(feature = "playtime")]
pub(crate) fn clip_play_state_unit_value(
action: helgobox_api::persistence::PlaytimeSlotTransportAction,
play_state: playtime_clip_engine::rt::ClipPlayState,
) -> UnitValue {
use helgobox_api::persistence::PlaytimeSlotTransportAction::*;
use playtime_clip_engine::rt::ClipPlayState;
match action {
Trigger | PlayStop | PlayPause | RecordPlayStop => play_state.feedback_value(),
Stop => transport_is_enabled_unit_value(matches!(
play_state,
ClipPlayState::Stopped | ClipPlayState::Ignited
)),
Pause => transport_is_enabled_unit_value(play_state == ClipPlayState::Paused),
RecordStop | OverdubPlay => {
transport_is_enabled_unit_value(play_state.is_and_will_keep_recording())
}
Looped => panic!("wrong argument"),
}
}
@@ -0,0 +1,151 @@
use crate::domain::{ControlContext, PluginParams};
use anyhow::{bail, Context};
use derivative::Derivative;
use reaper_high::{Fx, FxChainContext, Project, Reaper, Track};
use reaper_low::{static_plugin_context, PluginContext};
use reaper_medium::{MainThreadScope, ParamId, TrackFxLocation, TypeSpecificPluginContext};
use std::ptr::NonNull;
use vst::host::Host;
use vst::plugin::HostCallback;
#[derive(Copy, Clone, Debug)]
pub struct ExtendedProcessorContext<'a> {
pub context: &'a ProcessorContext,
pub params: &'a PluginParams,
pub control_context: ControlContext<'a>,
}
impl<'a> ExtendedProcessorContext<'a> {
pub fn new(
context: &'a ProcessorContext,
params: &'a PluginParams,
control_context: ControlContext<'a>,
) -> Self {
Self {
context,
params,
control_context,
}
}
pub fn context(&self) -> &'a ProcessorContext {
self.context
}
pub fn params(&self) -> &'a PluginParams {
self.params
}
pub fn control_context(&self) -> ControlContext {
self.control_context
}
}
#[derive(Clone, Derivative)]
#[derivative(Debug)]
pub struct ProcessorContext {
#[derivative(Debug = "ignore")]
host: HostCallback,
containing_fx: Fx,
project: Option<Project>,
bypass_param_index: u32,
}
pub const HELGOBOX_INSTANCE_ID_KEY: &str = "instance_id";
impl ProcessorContext {
pub fn from_host(host: HostCallback) -> anyhow::Result<ProcessorContext> {
let fx = get_containing_fx(&host)?;
let project = fx.project();
let bypass_param = fx
.parameter_by_id(ParamId::Bypass)
.context("bypass parameter not found")?;
let context = ProcessorContext {
host,
containing_fx: fx,
project,
bypass_param_index: bypass_param.index(),
};
Ok(context)
}
pub fn containing_fx(&self) -> &Fx {
&self.containing_fx
}
pub fn track(&self) -> Option<&Track> {
self.containing_fx.track()
}
/// Returns the index of ReaLearn's "Bypass" parameter.
pub fn bypass_param_index(&self) -> u32 {
self.bypass_param_index
}
/// This falls back to the current project if on the monitoring FX chain.
pub fn project_or_current_project(&self) -> Project {
self.project
.unwrap_or_else(|| Reaper::get().current_project())
}
pub fn project(&self) -> Option<Project> {
self.project
}
pub fn is_on_monitoring_fx_chain(&self) -> bool {
matches!(
self.containing_fx.chain().context(),
FxChainContext::Monitoring
)
}
pub fn notify_dirty(&self) {
self.host.automate(-1, 0.0);
}
}
/// Calling this in the `new()` method is too early. The containing FX can't generally be found
/// when we just open a REAPER project. We must wait for `init()` to be called. No! Even longer.
/// We need to wait until the next main loop cycle.
fn get_containing_fx(host: &HostCallback) -> anyhow::Result<Fx> {
let aeffect = NonNull::new(host.raw_effect()).context("aeffect must not be null")?;
// We must not use the plug-in context from the global `Reaper` instance because this was
// probably initialized by the extension entry point or another instance.
let plugin_context = PluginContext::from_vst_plugin(host, static_plugin_context())
.context("host callback not available")?;
let plugin_context = reaper_medium::PluginContext::<'_, MainThreadScope>::new(&plugin_context);
let vst_context = match plugin_context.type_specific() {
TypeSpecificPluginContext::Vst(ctx) => ctx,
_ => unreachable!(),
};
let fx_location = unsafe {
// This would fail if we would call it too soon. That's why this function needs to be
// called in the main loop cycle after loading the VST.
vst_context
.request_containing_fx_location(aeffect)
.context("This version of ReaLearn needs REAPER >= v6.11.")?
};
let fx = if let Some(track) = unsafe { vst_context.request_containing_track(aeffect) } {
let project = unsafe { vst_context.request_containing_project(aeffect) };
let track = Track::new(track, Some(project));
let (fx_chain, index) = match fx_location {
TrackFxLocation::NormalFxChain(index) => (track.normal_fx_chain(), index),
TrackFxLocation::InputFxChain(index) => (track.input_fx_chain(), index),
TrackFxLocation::Unknown(_) => {
bail!("Unknown ReaLearn FX location");
}
};
fx_chain
.fx_by_index(index)
.context("couldn't find containing FX on track FX chains")?
} else if let Some(_take) = unsafe { vst_context.request_containing_take(aeffect) } {
bail!("Sorry, Helgobox doesn't support operation as item/take FX!");
} else {
let TrackFxLocation::InputFxChain(index) = fx_location else {
bail!("Sorry, Helgobox doesn't support operation within an FX container!",);
};
Reaper::get().monitoring_fx_chain().fx_by_index(index)
.context("Couldn't find containing FX on monitoring FX chain. It's okay if this occurs during plug-in scanning.")?
};
Ok(fx)
}
@@ -0,0 +1,702 @@
use crate::domain::{
convert_reaper_color_to_helgoboss_learn, get_fx_name, get_track_name, Backbone,
CompoundChangeEvent, CompoundMappingTarget, ControlContext, FeedbackResolution, MainMapping,
RealearnTarget, ReaperTarget, UnresolvedCompoundMappingTarget,
};
use enum_dispatch::enum_dispatch;
use helgoboss_learn::{AbsoluteValue, NumericValue, PropProvider, PropValue, Target};
use helgobox_api::persistence::TrackScope;
use reaper_high::ChangeEvent;
use std::str::FromStr;
/// `None` means that no polling is necessary for feedback because we are notified via events.
pub fn prop_feedback_resolution(
key: &str,
mapping: &MainMapping,
target: &UnresolvedCompoundMappingTarget,
) -> Option<FeedbackResolution> {
match key.parse::<Props>().ok() {
Some(props) => props.feedback_resolution(mapping, target),
None => {
// Maybe target-specific placeholder. At the moment we should only have target-specific
// placeholders whose feedback resolution is the same resolution as the one of the
// main target value, so the following is good enough. If this changes in future, we
// should introduce a similar function in ReaLearn target (one that takes a key).
target.feedback_resolution()
}
}
}
pub fn prop_is_affected_by(
key: &str,
event: CompoundChangeEvent,
mapping: &MainMapping,
target: &ReaperTarget,
control_context: ControlContext,
) -> bool {
match key.parse::<Props>().ok() {
Some(props) => {
// TODO-medium Not very consequent? Here we take the first target and for
// target-specific placeholders the given one. A bit hard to change though. Let's see.
props.is_affected_by(event, mapping, mapping.targets().first(), control_context)
}
None => {
// Maybe target-specific placeholder. At the moment we should only have target-specific
// placeholders that are affected by changes of the main target value, so the following
// is good enough. If this changes in future, we should introduce a similar function
// in ReaLearn target (one that takes a key).
if key.starts_with("target.") {
target.process_change_event(event, control_context).0
} else {
false
}
}
}
}
pub struct MappingPropProvider<'a> {
mapping: &'a MainMapping,
context: ControlContext<'a>,
}
impl<'a> MappingPropProvider<'a> {
pub fn new(mapping: &'a MainMapping, context: ControlContext<'a>) -> Self {
Self { mapping, context }
}
}
impl PropProvider for MappingPropProvider<'_> {
fn get_prop_value(&self, key: &str) -> Option<PropValue> {
match key.parse::<Props>().ok() {
Some(props) => {
props.get_value(self.mapping, self.mapping.targets().first(), self.context)
}
None => {
let target = self.mapping.targets().first()?;
if key == "y" {
let y = target.current_value(self.context)?;
Some(PropValue::Normalized(y.to_unit_value()))
} else if let Some(key) = key.strip_prefix("target.") {
target.prop_value(key, self.context)
} else {
None
}
}
}
}
}
enum Props {
Global(GlobalProps),
Mapping(MappingProps),
Target(TargetProps),
}
impl Props {
/// `None` means that no polling is necessary for feedback because we are notified via events.
pub fn feedback_resolution(
&self,
mapping: &MainMapping,
target: &UnresolvedCompoundMappingTarget,
) -> Option<FeedbackResolution> {
match self {
Props::Global(p) => {
let args = PropFeedbackResolutionArgs { object: () };
p.feedback_resolution(args)
}
Props::Mapping(p) => {
let args = PropFeedbackResolutionArgs { object: mapping };
p.feedback_resolution(args)
}
Props::Target(p) => {
let args = PropFeedbackResolutionArgs {
object: MappingAndUnresolvedTarget { mapping, target },
};
p.feedback_resolution(args)
}
}
}
/// Returns whether the value of this property could be affected by the given change event.
pub fn is_affected_by(
&self,
event: CompoundChangeEvent,
mapping: &MainMapping,
target: Option<&CompoundMappingTarget>,
control_context: ControlContext,
) -> bool {
match self {
Props::Global(p) => {
let args = PropIsAffectedByArgs {
event,
object: (),
control_context,
};
p.is_affected_by(args)
}
Props::Mapping(p) => {
let args = PropIsAffectedByArgs {
event,
object: mapping,
control_context,
};
p.is_affected_by(args)
}
Props::Target(p) => target
.map(|target| {
let args = PropIsAffectedByArgs {
event,
object: MappingAndTarget { mapping, target },
control_context,
};
p.is_affected_by(args)
})
.unwrap_or(false),
}
}
/// Returns the current value of this property.
pub fn get_value(
&self,
mapping: &MainMapping,
target: Option<&CompoundMappingTarget>,
control_context: ControlContext,
) -> Option<PropValue> {
match self {
Props::Global(p) => {
let args = PropGetValueArgs {
object: (),
control_context,
};
p.get_value(args)
}
Props::Mapping(p) => {
let args = PropGetValueArgs {
object: mapping,
control_context,
};
p.get_value(args)
}
Props::Target(p) => target.and_then(|target| {
let args = PropGetValueArgs {
object: MappingAndTarget { mapping, target },
control_context,
};
p.get_value(args)
}),
}
}
}
impl FromStr for Props {
type Err = strum::ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
s.parse::<GlobalProps>()
.map(Props::Global)
.or_else(|_| s.parse::<MappingProps>().map(Props::Mapping))
.or_else(|_| s.parse::<TargetProps>().map(Props::Target))
}
}
#[enum_dispatch]
#[derive(strum::EnumString)]
enum GlobalProps {
#[strum(serialize = "global.realearn.time")]
GlobalRealearnTime(GlobalRealearnTimeProp),
}
#[enum_dispatch]
#[derive(strum::EnumString)]
enum MappingProps {
#[strum(serialize = "mapping.name")]
Name(MappingNameProp),
}
#[enum_dispatch]
#[derive(strum::EnumString)]
enum TargetProps {
#[strum(serialize = "target.type.name")]
TargetTypeName(TargetTypeNameProp),
#[strum(serialize = "target.type.long_name")]
TargetTypeLongName(TargetTypeLongNameProp),
#[strum(serialize = "target.available")]
TargetAvailable(TargetAvailableProp),
#[strum(serialize = "target.text_value")]
TextValue(TargetTextValueProp),
#[strum(serialize = "target.discrete_value")]
DiscreteValue(TargetDiscreteValueProp),
#[strum(serialize = "target.discrete_value_count")]
DiscreteValueCount(TargetDiscreteValueCountProp),
#[strum(serialize = "target.numeric_value")]
NumericValue(TargetNumericValueProp),
#[strum(serialize = "target.numeric_value.unit")]
NumericValueUnit(TargetNumericValueUnitProp),
#[strum(serialize = "target.normalized_value")]
NormalizedValue(TargetNormalizedValueProp),
#[strum(serialize = "target.track.index")]
TrackIndex(TargetTrackIndexProp),
#[strum(serialize = "target.track.name")]
TrackName(TargetTrackNameProp),
#[strum(serialize = "target.track.color")]
TrackColor(TargetTrackColorProp),
#[strum(serialize = "target.fx.index")]
FxIndex(TargetFxIndexProp),
#[strum(serialize = "target.fx.name")]
FxName(TargetFxNameProp),
#[strum(serialize = "target.route.index")]
RouteIndex(TargetRouteIndexProp),
#[strum(serialize = "target.route.name")]
RouteName(TargetRouteNameProp),
#[strum(serialize = "target.slot.color")]
PlaytimeSlotColor(TargetPlaytimeSlotColorProp),
}
#[enum_dispatch(GlobalProps)]
trait GlobalProp {
/// `None` means that no polling is necessary for feedback because we are notified via events.
fn feedback_resolution(
&self,
args: PropFeedbackResolutionArgs<()>,
) -> Option<FeedbackResolution> {
let _ = args;
None
}
/// Returns whether the value of this property could be affected by the given change event.
fn is_affected_by(&self, args: PropIsAffectedByArgs<()>) -> bool;
/// Returns the current value of this property.
fn get_value(&self, args: PropGetValueArgs<()>) -> Option<PropValue>;
}
#[enum_dispatch(MappingProps)]
trait MappingProp {
/// `None` means that no polling is necessary for feedback because we are notified via events.
fn feedback_resolution(
&self,
args: PropFeedbackResolutionArgs<&MainMapping>,
) -> Option<FeedbackResolution> {
let _ = args;
None
}
/// Returns whether the value of this property could be affected by the given change event.
fn is_affected_by(&self, args: PropIsAffectedByArgs<&MainMapping>) -> bool;
/// Returns the current value of this property.
fn get_value(&self, args: PropGetValueArgs<&MainMapping>) -> Option<PropValue>;
}
#[enum_dispatch(TargetProps)]
trait TargetProp {
/// `None` means that no polling is necessary for feedback because we are notified via events.
fn feedback_resolution(
&self,
args: PropFeedbackResolutionArgs<MappingAndUnresolvedTarget>,
) -> Option<FeedbackResolution> {
let _ = args;
None
}
/// Returns whether the value of this property could be affected by the given change event.
fn is_affected_by(&self, args: PropIsAffectedByArgs<MappingAndTarget>) -> bool {
// Many target props change whenever the main target value changes. So this is the default.
args.object
.target
.process_change_event(args.event, args.control_context)
.0
}
/// Returns the current value of this property.
fn get_value(&self, args: PropGetValueArgs<MappingAndTarget>) -> Option<PropValue>;
}
#[allow(dead_code)]
struct MappingAndTarget<'a> {
pub mapping: &'a MainMapping,
pub target: &'a CompoundMappingTarget,
}
#[allow(dead_code)]
struct MappingAndUnresolvedTarget<'a> {
pub mapping: &'a MainMapping,
pub target: &'a UnresolvedCompoundMappingTarget,
}
#[allow(dead_code)]
struct PropFeedbackResolutionArgs<T> {
object: T,
}
struct PropIsAffectedByArgs<'a, T> {
event: CompoundChangeEvent<'a>,
object: T,
control_context: ControlContext<'a>,
}
struct PropGetValueArgs<'a, T> {
object: T,
control_context: ControlContext<'a>,
}
#[derive(Default)]
struct GlobalRealearnTimeProp;
impl GlobalProp for GlobalRealearnTimeProp {
fn feedback_resolution(&self, _: PropFeedbackResolutionArgs<()>) -> Option<FeedbackResolution> {
Some(FeedbackResolution::High)
}
fn is_affected_by(&self, _: PropIsAffectedByArgs<()>) -> bool {
false
}
fn get_value(&self, _: PropGetValueArgs<()>) -> Option<PropValue> {
Some(PropValue::DurationInMillis(
Backbone::get().duration_since_time_of_start().as_millis() as _,
))
}
}
#[derive(Default)]
struct MappingNameProp;
impl MappingProp for MappingNameProp {
fn is_affected_by(&self, _: PropIsAffectedByArgs<&MainMapping>) -> bool {
// Mapping name changes will result in a full mapping resync anyway.
false
}
fn get_value(&self, input: PropGetValueArgs<&MainMapping>) -> Option<PropValue> {
let instance_state = input.control_context.unit.borrow();
let info = instance_state.get_mapping_info(input.object.qualified_id())?;
Some(PropValue::Text(info.name.clone().into()))
}
}
#[derive(Default)]
struct TargetTextValueProp;
impl TargetProp for TargetTextValueProp {
fn feedback_resolution(
&self,
args: PropFeedbackResolutionArgs<MappingAndUnresolvedTarget>,
) -> Option<FeedbackResolution> {
args.object.target.feedback_resolution()
}
fn get_value(&self, input: PropGetValueArgs<MappingAndTarget>) -> Option<PropValue> {
Some(PropValue::Text(
input.object.target.text_value(input.control_context)?,
))
}
}
#[derive(Default)]
struct TargetDiscreteValueCountProp;
impl TargetProp for TargetDiscreteValueCountProp {
fn get_value(&self, args: PropGetValueArgs<MappingAndTarget>) -> Option<PropValue> {
let discrete_count = args
.object
.target
.control_type_and_character(args.control_context)
.0
.discrete_count()?;
Some(PropValue::Numeric(NumericValue::Discrete(
discrete_count as _,
)))
}
}
#[derive(Default)]
struct TargetDiscreteValueProp;
impl TargetProp for TargetDiscreteValueProp {
fn get_value(&self, args: PropGetValueArgs<MappingAndTarget>) -> Option<PropValue> {
let i = match args.object.target.current_value(args.control_context)? {
AbsoluteValue::Continuous(uv) => {
let step_size = args
.object
.target
.control_type_and_character(args.control_context)
.0
.step_size()?;
(uv.get() / step_size.get()).round() as u32
}
AbsoluteValue::Discrete(d) => d.actual(),
};
Some(PropValue::Index(i))
}
}
#[derive(Default)]
struct TargetNumericValueProp;
impl TargetProp for TargetNumericValueProp {
fn feedback_resolution(
&self,
args: PropFeedbackResolutionArgs<MappingAndUnresolvedTarget>,
) -> Option<FeedbackResolution> {
args.object.target.feedback_resolution()
}
fn get_value(&self, args: PropGetValueArgs<MappingAndTarget>) -> Option<PropValue> {
Some(PropValue::Numeric(
args.object.target.numeric_value(args.control_context)?,
))
}
}
#[derive(Default)]
struct TargetNormalizedValueProp;
impl TargetProp for TargetNormalizedValueProp {
fn feedback_resolution(
&self,
args: PropFeedbackResolutionArgs<MappingAndUnresolvedTarget>,
) -> Option<FeedbackResolution> {
args.object.target.feedback_resolution()
}
fn get_value(&self, args: PropGetValueArgs<MappingAndTarget>) -> Option<PropValue> {
Some(PropValue::Normalized(
args.object
.target
.current_value(args.control_context)?
.to_unit_value(),
))
}
}
#[derive(Default)]
struct TargetTrackIndexProp;
impl TargetProp for TargetTrackIndexProp {
fn is_affected_by(&self, args: PropIsAffectedByArgs<MappingAndTarget>) -> bool {
matches!(
args.event,
CompoundChangeEvent::Reaper(
ChangeEvent::TrackAdded(_)
| ChangeEvent::TrackRemoved(_)
| ChangeEvent::TracksReordered(_)
)
)
}
fn get_value(&self, args: PropGetValueArgs<MappingAndTarget>) -> Option<PropValue> {
Some(PropValue::Index(args.object.target.track()?.index()?))
}
}
#[derive(Default)]
struct TargetFxIndexProp;
impl TargetProp for TargetFxIndexProp {
fn feedback_resolution(
&self,
_: PropFeedbackResolutionArgs<MappingAndUnresolvedTarget>,
) -> Option<FeedbackResolution> {
// This is unfortunately necessary because it's possible that the targeted FX is on the
// monitoring FX chain. This chain doesn't support notifications, so `is_affected_by`
// won't work.
Some(FeedbackResolution::High)
}
fn get_value(&self, args: PropGetValueArgs<MappingAndTarget>) -> Option<PropValue> {
Some(PropValue::Index(args.object.target.fx()?.index()))
}
}
#[derive(Default)]
struct TargetTrackNameProp;
impl TargetProp for TargetTrackNameProp {
fn is_affected_by(&self, args: PropIsAffectedByArgs<MappingAndTarget>) -> bool {
// This could be more specific (taking the track into account) but so what.
// This doesn't happen that frequently.
matches!(args.event, CompoundChangeEvent::Reaper(ChangeEvent::TrackNameChanged(e)) if Some(&e.track) == args.object.target.track())
}
fn get_value(&self, args: PropGetValueArgs<MappingAndTarget>) -> Option<PropValue> {
let name = get_track_name(args.object.target.track()?, TrackScope::AllTracks);
Some(PropValue::Text(name.into()))
}
}
#[derive(Default)]
struct TargetNumericValueUnitProp;
impl TargetProp for TargetNumericValueUnitProp {
fn is_affected_by(&self, _: PropIsAffectedByArgs<MappingAndTarget>) -> bool {
// Static in nature (change only when target settings change).
false
}
fn get_value(&self, args: PropGetValueArgs<MappingAndTarget>) -> Option<PropValue> {
Some(PropValue::Text(
args.object
.target
.numeric_value_unit(args.control_context)
.into(),
))
}
}
#[derive(Default)]
struct TargetTypeNameProp;
impl TargetProp for TargetTypeNameProp {
fn is_affected_by(&self, _: PropIsAffectedByArgs<MappingAndTarget>) -> bool {
// Static in nature (change only when target settings change).
false
}
fn get_value(&self, args: PropGetValueArgs<MappingAndTarget>) -> Option<PropValue> {
Some(PropValue::Text(
args.object.target.reaper_target_type()?.short_name().into(),
))
}
}
#[derive(Default)]
struct TargetTypeLongNameProp;
impl TargetProp for TargetTypeLongNameProp {
fn is_affected_by(&self, _: PropIsAffectedByArgs<MappingAndTarget>) -> bool {
// Static in nature (change only when target settings change).
false
}
fn get_value(&self, args: PropGetValueArgs<MappingAndTarget>) -> Option<PropValue> {
Some(PropValue::Text(
args.object.target.reaper_target_type()?.to_string().into(),
))
}
}
#[derive(Default)]
struct TargetAvailableProp;
impl TargetProp for TargetAvailableProp {
fn get_value(&self, args: PropGetValueArgs<MappingAndTarget>) -> Option<PropValue> {
let is_available = args.object.target.is_available(args.control_context);
Some(PropValue::Boolean(is_available))
}
}
#[derive(Default)]
struct TargetTrackColorProp;
impl TargetProp for TargetTrackColorProp {
fn feedback_resolution(
&self,
_: PropFeedbackResolutionArgs<MappingAndUnresolvedTarget>,
) -> Option<FeedbackResolution> {
// There are no appropriate change events for this property so we fall back to polling.
Some(FeedbackResolution::High)
}
fn get_value(&self, args: PropGetValueArgs<MappingAndTarget>) -> Option<PropValue> {
let color =
convert_reaper_color_to_helgoboss_learn(args.object.target.track()?.custom_color()?);
Some(PropValue::Color(color))
}
}
#[derive(Default)]
struct TargetPlaytimeSlotColorProp;
impl TargetProp for TargetPlaytimeSlotColorProp {
fn is_affected_by(&self, args: PropIsAffectedByArgs<MappingAndTarget>) -> bool {
#[cfg(not(feature = "playtime"))]
{
let _ = args;
false
}
#[cfg(feature = "playtime")]
{
use playtime_clip_engine::base::*;
use playtime_clip_engine::rt::*;
matches!(
args.event,
CompoundChangeEvent::ClipMatrix(
ClipMatrixEvent::TrackChanged(_)
| ClipMatrixEvent::ClipChanged(QualifiedClipChangeEvent {
event: ClipChangeEvent::Content | ClipChangeEvent::Everything,
..
})
)
)
}
}
fn get_value(&self, args: PropGetValueArgs<MappingAndTarget>) -> Option<PropValue> {
#[cfg(not(feature = "playtime"))]
{
let _ = args;
None
}
#[cfg(feature = "playtime")]
{
let slot_address = args.object.target.clip_slot_address()?;
let instance = args.control_context.instance.borrow();
let matrix = instance.clip_matrix()?;
let reaper_color = matrix.resolve_slot_color(slot_address)?;
let final_color = convert_reaper_color_to_helgoboss_learn(reaper_color);
Some(PropValue::Color(final_color))
}
}
}
#[derive(Default)]
struct TargetFxNameProp;
// There are no appropriate REAPER change events for this property.
impl TargetProp for TargetFxNameProp {
fn feedback_resolution(
&self,
_: PropFeedbackResolutionArgs<MappingAndUnresolvedTarget>,
) -> Option<FeedbackResolution> {
// This is unfortunately necessary because it's possible that the targeted FX is on the
// monitoring FX chain. This chain doesn't support notifications, so `is_affected_by`
// won't work.
Some(FeedbackResolution::High)
}
fn get_value(&self, args: PropGetValueArgs<MappingAndTarget>) -> Option<PropValue> {
let name = get_fx_name(args.object.target.fx()?).into();
Some(PropValue::Text(name))
}
}
#[derive(Default)]
struct TargetRouteIndexProp;
// There are no appropriate REAPER change events for this property.
impl TargetProp for TargetRouteIndexProp {
fn get_value(&self, args: PropGetValueArgs<MappingAndTarget>) -> Option<PropValue> {
Some(PropValue::Index(args.object.target.route()?.index()))
}
}
#[derive(Default)]
struct TargetRouteNameProp;
impl TargetProp for TargetRouteNameProp {
fn is_affected_by(&self, args: PropIsAffectedByArgs<MappingAndTarget>) -> bool {
// This could be more specific (taking the route partner into account) but so what.
// Track names are not changed that frequently.
matches!(
args.event,
CompoundChangeEvent::Reaper(ChangeEvent::TrackNameChanged(_))
)
}
fn get_value(&self, args: PropGetValueArgs<MappingAndTarget>) -> Option<PropValue> {
Some(PropValue::Text(
args.object.target.route()?.name().into_string().into(),
))
}
}
@@ -0,0 +1,118 @@
use crate::domain::AudioBlockProps;
use std::sync::{Arc, Mutex, Weak};
pub type SharedRealTimeInstance = Arc<Mutex<RealTimeInstance>>;
pub type WeakRealTimeInstance = Weak<Mutex<RealTimeInstance>>;
const NORMAL_BULK_SIZE: usize = 100;
#[derive(Debug)]
pub struct RealTimeInstance {
task_receiver: crossbeam_channel::Receiver<RealTimeInstanceTask>,
#[cfg(feature = "playtime")]
playtime: PlaytimeRtInstance,
}
#[cfg(feature = "playtime")]
#[derive(Debug)]
struct PlaytimeRtInstance {
clip_matrix: Option<playtime_clip_engine::rt::WeakRtMatrix>,
clip_engine_fx_hook: playtime_clip_engine::rt::fx_hook::PlaytimeFxHook,
}
#[derive(Debug)]
#[allow(clippy::large_enum_variant)]
pub enum RealTimeInstanceTask {
#[cfg(feature = "playtime")]
SetClipMatrix {
matrix: Option<playtime_clip_engine::rt::WeakRtMatrix>,
},
#[cfg(feature = "playtime")]
PlaytimeClipEngineCommand(playtime_clip_engine::rt::fx_hook::PlaytimeFxHookCommand),
}
impl RealTimeInstance {
pub fn new(task_receiver: crossbeam_channel::Receiver<RealTimeInstanceTask>) -> Self {
Self {
task_receiver,
#[cfg(feature = "playtime")]
playtime: PlaytimeRtInstance {
clip_matrix: None,
clip_engine_fx_hook: playtime_clip_engine::rt::fx_hook::PlaytimeFxHook::new(),
},
}
}
#[cfg(feature = "playtime")]
pub fn clip_matrix(&self) -> Option<&playtime_clip_engine::rt::WeakRtMatrix> {
self.playtime.clip_matrix.as_ref()
}
/// To be called from audio hook when `post == false`.
pub fn pre_poll(&mut self, block_props: AudioBlockProps) {
#[cfg(not(feature = "playtime"))]
{
let _ = block_props;
}
#[cfg(feature = "playtime")]
{
if let Some(clip_matrix) = self.playtime.clip_matrix.as_ref().and_then(|m| m.upgrade())
{
clip_matrix.lock().pre_poll(block_props.to_playtime());
}
}
#[allow(clippy::never_loop)]
for task in self.task_receiver.try_iter().take(NORMAL_BULK_SIZE) {
match task {
#[cfg(feature = "playtime")]
RealTimeInstanceTask::SetClipMatrix { matrix } => {
self.playtime.clip_matrix = matrix;
}
#[cfg(feature = "playtime")]
RealTimeInstanceTask::PlaytimeClipEngineCommand(command) => {
let _ = self
.playtime
.clip_engine_fx_hook
.process_command(command, block_props.to_playtime());
}
}
}
}
/// To be called from audio hook when `post == true`.
pub fn post_poll(&mut self) {
#[cfg(feature = "playtime")]
{
if let Some(clip_matrix) = self.playtime.clip_matrix.as_ref().and_then(|m| m.upgrade())
{
clip_matrix.lock().post_poll();
}
}
}
#[cfg(feature = "playtime")]
pub fn run_from_vst(
&mut self,
buffer: &mut vst::buffer::AudioBuffer<f64>,
block_props: AudioBlockProps,
) {
let inputs = VstChannelInputs(buffer.split().0);
self.playtime
.clip_engine_fx_hook
.poll(&inputs, block_props.to_playtime());
}
}
#[cfg(feature = "playtime")]
struct VstChannelInputs<'a>(vst::buffer::Inputs<'a, f64>);
#[cfg(feature = "playtime")]
impl playtime_clip_engine::rt::fx_hook::ChannelInputs for VstChannelInputs<'_> {
fn channel_count(&self) -> usize {
self.0.len()
}
fn get_channel_data(&self, channel_index: usize) -> &[f64] {
self.0.get(channel_index)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,20 @@
use base::hash_util::NonCryptoHashMap;
use helgoboss_learn::devices::x_touch::XTouchMackieLcdState;
use reaper_medium::MidiOutputDeviceId;
/// Global state about sources.
#[derive(Default)]
pub struct RealearnSourceState {
x_touch_mackie_lcd_state_by_device: NonCryptoHashMap<MidiOutputDeviceId, XTouchMackieLcdState>,
}
impl RealearnSourceState {
pub fn get_x_touch_mackie_lcd_state_mut(
&mut self,
device: MidiOutputDeviceId,
) -> &mut XTouchMackieLcdState {
self.x_touch_mackie_lcd_state_by_device
.entry(device)
.or_default()
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,184 @@
use crate::domain::{
AdditionalFeedbackEvent, FxSnapshotLoadedEvent, ParameterAutomationTouchStateChangedEvent,
TouchedTrackParameterType, TrackGangBehavior,
};
use base::hash_util::{NonCryptoHashMap, NonCryptoHashSet};
use base::{NamedChannelSender, SenderToNormalThread};
use reaper_high::{Fx, Track, TrackSetSmartOpts};
use reaper_medium::MediaTrack;
/// Feedback for most targets comes from REAPER itself but there are some targets for which ReaLearn
/// holds the state. It's in this struct.
///
/// Some of this state can be persistent. This raises the question which ReaLearn instance should
/// be responsible for saving it. If you need persistent state, first think about if it shouldn't
/// rather be part of `InstanceState`. Then it's owned by a particular instance, which is then also
/// responsible for saving it. But we also have global REAPER things such as additional FX state.
/// In this case, we should put it here and track for each state which instance is responsible for
/// saving it!
pub struct RealearnTargetState {
/// For notifying ReaLearn about state changes.
additional_feedback_event_sender: SenderToNormalThread<AdditionalFeedbackEvent>,
/// Memorizes for each FX the hash of its last FX snapshot loaded via "Load FX snapshot" target.
///
/// Persistent.
// TODO-high-pot Restore on load (by looking up snapshot chunk)
fx_snapshot_chunk_hash_by_fx: NonCryptoHashMap<Fx, u64>,
/// Memorizes for each FX some infos about its last loaded Pot preset.
///
/// Persistent.
// TODO-high-pot Restore on load (by looking up DB)
current_pot_preset_by_fx: NonCryptoHashMap<Fx, pot::CurrentPreset>,
/// Memorizes all currently touched track parameters.
///
/// For "Touch automation state" target.
///
/// Not persistent.
touched_things: NonCryptoHashSet<TouchedThing>,
}
#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)]
struct TouchedThing {
track: MediaTrack,
parameter_type: TouchedTrackParameterType,
}
impl TouchedThing {
pub fn new(track: MediaTrack, parameter_type: TouchedTrackParameterType) -> Self {
Self {
track,
parameter_type,
}
}
}
impl RealearnTargetState {
pub fn new(
additional_feedback_event_sender: SenderToNormalThread<AdditionalFeedbackEvent>,
) -> Self {
Self {
additional_feedback_event_sender,
fx_snapshot_chunk_hash_by_fx: Default::default(),
touched_things: Default::default(),
current_pot_preset_by_fx: Default::default(),
}
}
pub fn current_fx_preset(&self, fx: &Fx) -> Option<&pot::CurrentPreset> {
let fx = fx.guid_based()?;
self.current_pot_preset_by_fx.get(&fx)
}
pub fn set_current_fx_preset(&mut self, fx: Fx, current_preset: pot::CurrentPreset) {
// reaper_high::Fx is really not a good candidate for hashing. At the moment, we must
// "normalize" it to be guid-based to really match. But it's still dirty, even the FX
// chain and track matching. We need several distinct types!
let Some(fx) = fx.guid_based() else {
return;
};
self.current_pot_preset_by_fx.insert(fx, current_preset);
self.additional_feedback_event_sender
.send_complaining(AdditionalFeedbackEvent::MappedFxParametersChanged);
}
pub fn current_fx_snapshot_chunk_hash(&self, fx: &Fx) -> Option<u64> {
self.fx_snapshot_chunk_hash_by_fx.get(fx).copied()
}
pub fn load_fx_snapshot(
&mut self,
fx: Fx,
chunk: &str,
chunk_hash: u64,
) -> Result<(), &'static str> {
fx.set_tag_chunk(chunk)?;
// fx.set_vst_chunk_encoded(chunk.to_string())?;
self.fx_snapshot_chunk_hash_by_fx
.insert(fx.clone(), chunk_hash);
self.additional_feedback_event_sender.send_complaining(
AdditionalFeedbackEvent::FxSnapshotLoaded(FxSnapshotLoadedEvent { fx }),
);
Ok(())
}
pub fn touch_automation_parameter(
&mut self,
track: &Track,
parameter_type: TouchedTrackParameterType,
gang_behavior: TrackGangBehavior,
) {
let Ok(raw_track) = track.raw() else {
return;
};
self.touched_things
.insert(TouchedThing::new(raw_track, parameter_type));
self.post_process_touch(track, parameter_type, gang_behavior, true);
self.additional_feedback_event_sender.send_complaining(
AdditionalFeedbackEvent::ParameterAutomationTouchStateChanged(
ParameterAutomationTouchStateChangedEvent {
track: raw_track,
parameter_type,
new_value: true,
},
),
);
}
pub fn untouch_automation_parameter(
&mut self,
track: &Track,
parameter_type: TouchedTrackParameterType,
gang_behavior: TrackGangBehavior,
) {
let Ok(raw_track) = track.raw() else {
return;
};
self.touched_things
.remove(&TouchedThing::new(raw_track, parameter_type));
self.post_process_touch(track, parameter_type, gang_behavior, false);
self.additional_feedback_event_sender.send_complaining(
AdditionalFeedbackEvent::ParameterAutomationTouchStateChanged(
ParameterAutomationTouchStateChangedEvent {
track: raw_track,
parameter_type,
new_value: false,
},
),
);
}
fn post_process_touch(
&mut self,
track: &Track,
parameter_type: TouchedTrackParameterType,
gang_behavior: TrackGangBehavior,
touched: bool,
) {
let (gang_behavior, grouping_behavior) = gang_behavior.gang_and_grouping_behavior();
let opts = TrackSetSmartOpts {
grouping_behavior,
gang_behavior,
done: !touched,
};
match parameter_type {
TouchedTrackParameterType::Volume => {
let _ = track.set_volume_smart(track.volume(), opts);
}
TouchedTrackParameterType::Pan => {
let _ = track.set_pan_smart(track.pan().reaper_value(), opts);
}
TouchedTrackParameterType::Width => {
let _ = track.set_width_smart(track.width().reaper_value(), opts);
}
}
}
pub fn automation_parameter_is_touched(
&self,
track: MediaTrack,
parameter_type: TouchedTrackParameterType,
) -> bool {
self.touched_things
.contains(&TouchedThing::new(track, parameter_type))
}
}
@@ -0,0 +1,44 @@
use reaper_high::Reaper;
#[derive(Debug, Default)]
pub struct ReaperConfigChangeDetector {
project_options: ProjectOptions,
}
#[derive(Copy, Clone, Eq, PartialEq, Debug, Default)]
pub struct ProjectOptions {
pub run_background_projects: bool,
pub run_stopped_background_projects: bool,
}
#[derive(Debug)]
pub enum ReaperConfigChange {
ProjectOptions(ProjectOptions),
}
impl ReaperConfigChangeDetector {
pub fn poll_for_changes(&mut self) -> Vec<ReaperConfigChange> {
let mut changes = vec![];
let project_options = get_project_options();
if project_options != self.project_options {
self.project_options = project_options;
changes.push(ReaperConfigChange::ProjectOptions(project_options));
}
changes
}
}
pub fn get_project_options() -> ProjectOptions {
if let Some(res) = Reaper::get().medium_reaper().get_config_var("multiprojopt") {
assert!(res.size > 0, "multiprojopt value should have size > 0");
let bit_mask = unsafe { res.value.cast::<u8>().as_ref() };
ProjectOptions {
// Bit 0 = disable_background_projects
run_background_projects: (*bit_mask & (1 << 0)) == 0,
// Bit 1 = enable_stopped_background_projects
run_stopped_background_projects: (*bit_mask & (1 << 1)) != 0,
}
} else {
Default::default()
}
}
@@ -0,0 +1,324 @@
use crate::domain::{
CompartmentKind, CompartmentParamIndex, RawParamValue, ReaperSourceAddress, StreamDeckDeviceId,
};
use base::hash_util::NonCryptoHashSet;
use core::fmt;
use derive_more::Display;
use helgoboss_learn::{
format_percentage_without_unit, parse_percentage_without_unit, ControlValue,
DetailedSourceCharacter, FeedbackValue, SourceCharacter, UnitValue,
};
use reaper_medium::{MidiInputDeviceId, MidiOutputDeviceId};
use std::convert::TryInto;
use std::error::Error;
use std::fmt::{Display, Formatter};
use std::time::{Duration, Instant};
#[derive(Clone, Eq, PartialEq, Debug)]
pub enum ReaperSource {
MidiDeviceChanges,
RealearnInstanceStart,
RealearnCompartmentLoaded,
Timer(TimerSource),
RealearnParameter(RealearnParameterSource),
Speech(SpeechSource),
}
#[derive(Clone, Eq, PartialEq, Debug, Default)]
pub struct SpeechSource {}
impl SpeechSource {
pub fn new() -> Self {
Self::default()
}
pub fn feedback(&self, feedback_value: &FeedbackValue) -> SpeechSourceFeedbackValue {
SpeechSourceFeedbackValue {
text: feedback_value.to_textual().text.to_string(),
}
}
}
pub fn say(feedback_value: SpeechSourceFeedbackValue) -> Result<(), Box<dyn Error>> {
#[cfg(any(target_os = "windows", target_os = "macos"))]
{
fn get_default_tts() -> Result<tts::Tts, tts::Error> {
#[cfg(target_os = "macos")]
{
let mut tts = tts::Tts::default()?;
// On macOS, at least with AVFoundation, it's necessary to set a voice first.
// Prefer an English voice as default.
if let Ok(voices) = tts.voices() {
let voice = voices
.iter()
.find(|v| v.language().language.as_str() == "en")
.or_else(|| voices.first());
if let Some(v) = voice {
tts.set_voice(v)?;
}
}
Ok(tts)
}
#[cfg(target_os = "windows")]
{
tts::Tts::default()
}
}
use once_cell::sync::Lazy;
use std::sync::Mutex;
static TTS: Lazy<Result<Mutex<tts::Tts>, tts::Error>> =
Lazy::new(|| get_default_tts().map(Mutex::new));
let tts = TTS.as_ref()?;
// TODO-medium This is only necessary because tts exposes a non-optimal API.
let mut tts = tts.lock()?;
// TODO-medium This cloning is totally unnecessary but ... non-optimal API.
tts.speak(feedback_value.text, true)?;
Ok(())
}
#[cfg(target_os = "linux")]
{
let _ = feedback_value;
Err("speech source not yet supported on Linux".into())
}
}
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
pub struct RealearnParameterSource {
pub parameter_index: CompartmentParamIndex,
}
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
pub struct TimerSource {
duration: Duration,
last_fire: Option<Instant>,
}
impl TimerSource {
pub fn new(interval: Duration) -> Self {
Self {
duration: interval,
last_fire: None,
}
}
pub fn on_deactivate(&mut self) {
self.last_fire = None;
}
pub fn poll(&mut self) -> Option<ControlValue> {
let now = Instant::now();
if let Some(last_fire) = self.last_fire {
let elapsed = now - last_fire;
if elapsed >= self.duration {
Some(self.fire(now))
} else {
None
}
} else {
Some(self.fire(now))
}
}
fn fire(&mut self, now: Instant) -> ControlValue {
self.last_fire = Some(now);
ControlValue::AbsoluteContinuous(UnitValue::MAX)
}
}
impl ReaperSource {
pub fn extract_feedback_address(&self) -> Option<ReaperSourceAddress> {
use ReaperSource::*;
match self {
Speech(_) => Some(ReaperSourceAddress::GlobalSpeech),
_ => None,
}
}
pub fn on_deactivate(&mut self) {
match self {
ReaperSource::Timer(s) => s.on_deactivate(),
_ => {}
}
}
/// If this returns `true`, the `poll` method should be called, on a regular basis.
pub fn wants_to_be_polled(&self) -> bool {
matches!(self, ReaperSource::Timer(_))
}
pub fn possible_detailed_characters(&self) -> Vec<DetailedSourceCharacter> {
use ReaperSource::*;
match self {
MidiDeviceChanges => vec![DetailedSourceCharacter::MomentaryOnOffButton],
RealearnInstanceStart => vec![DetailedSourceCharacter::MomentaryOnOffButton],
RealearnCompartmentLoaded => vec![DetailedSourceCharacter::Trigger],
Timer(_) => vec![DetailedSourceCharacter::Trigger],
RealearnParameter(_) => vec![
DetailedSourceCharacter::RangeControl,
DetailedSourceCharacter::MomentaryVelocitySensitiveButton,
DetailedSourceCharacter::MomentaryOnOffButton,
DetailedSourceCharacter::Trigger,
],
Speech(_) => vec![DetailedSourceCharacter::RangeControl],
}
}
pub fn format_control_value(&self, value: ControlValue) -> Result<String, &'static str> {
let formatted = format_percentage_without_unit(value.to_unit_value()?.get());
Ok(formatted)
}
pub fn parse_control_value(&self, text: &str) -> Result<UnitValue, &'static str> {
parse_percentage_without_unit(text)?.try_into()
}
pub fn character(&self) -> SourceCharacter {
use ReaperSource::*;
match self {
MidiDeviceChanges | RealearnInstanceStart | RealearnCompartmentLoaded | Timer(_) => {
SourceCharacter::MomentaryButton
}
RealearnParameter(_) => SourceCharacter::RangeElement,
Speech(_) => SourceCharacter::RangeElement,
}
}
pub fn poll(&mut self) -> Option<ControlValue> {
if let ReaperSource::Timer(t) = self {
t.poll()
} else {
None
}
}
pub fn control(
&mut self,
msg: &ReaperMessage,
compartment: CompartmentKind,
) -> Option<ControlValue> {
use ReaperMessage::*;
let control_value = match msg {
StreamDeckDevicesConnected(_) => {
// We don't have a corresponding source yet
return None;
}
MidiDevicesConnected(_) => match self {
ReaperSource::MidiDeviceChanges => ControlValue::AbsoluteContinuous(UnitValue::MAX),
_ => return None,
},
MidiDevicesDisconnected(_) => match self {
ReaperSource::MidiDeviceChanges => ControlValue::AbsoluteContinuous(UnitValue::MIN),
_ => return None,
},
RealearnUnitStarted => match self {
ReaperSource::RealearnInstanceStart => {
ControlValue::AbsoluteContinuous(UnitValue::MAX)
}
_ => return None,
},
RealearnCompartmentLoaded(kind) => match self {
ReaperSource::RealearnCompartmentLoaded if *kind == compartment => {
ControlValue::AbsoluteContinuous(UnitValue::MAX)
}
_ => return None,
},
RealearnParameterChange(c) => match self {
ReaperSource::RealearnParameter(s)
if c.compartment == compartment && c.parameter_index == s.parameter_index =>
{
ControlValue::AbsoluteContinuous(UnitValue::new_clamped(c.value as f64))
}
_ => return None,
},
};
Some(control_value)
}
pub fn feedback(&self, feedback_value: &FeedbackValue) -> Option<ReaperSourceFeedbackValue> {
use ReaperSource::*;
match self {
MidiDeviceChanges
| RealearnInstanceStart
| RealearnCompartmentLoaded
| Timer(_)
| RealearnParameter(_) => None,
Speech(s) => Some(ReaperSourceFeedbackValue::Speech(
s.feedback(feedback_value),
)),
}
}
}
#[derive(Clone, Eq, PartialEq, Debug)]
pub enum ReaperSourceFeedbackValue {
Speech(SpeechSourceFeedbackValue),
}
impl ReaperSourceFeedbackValue {
pub fn extract_feedback_address(&self) -> Option<ReaperSourceAddress> {
match self {
ReaperSourceFeedbackValue::Speech(_) => Some(ReaperSourceAddress::GlobalSpeech),
}
}
}
#[derive(Clone, Eq, PartialEq, Debug)]
pub struct SpeechSourceFeedbackValue {
pub text: String,
}
#[derive(PartialEq, Debug, Display)]
pub enum ReaperMessage {
#[display(fmt = "MidiDevicesConnected ({_0})")]
MidiDevicesConnected(MidiDeviceChangePayload),
#[display(fmt = "MidiDevicesDisconnected ({_0})")]
MidiDevicesDisconnected(MidiDeviceChangePayload),
RealearnUnitStarted,
RealearnCompartmentLoaded(CompartmentKind),
RealearnParameterChange(RealearnParameterChangePayload),
StreamDeckDevicesConnected(StreamDeckDevicePayload),
}
#[derive(Copy, Clone, PartialEq, Debug)]
pub struct RealearnParameterChangePayload {
pub compartment: CompartmentKind,
pub parameter_index: CompartmentParamIndex,
pub value: RawParamValue,
}
impl Display for RealearnParameterChangePayload {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
write!(
f,
"Parameter {} with value {}",
self.parameter_index, self.value
)
}
}
#[derive(Eq, PartialEq, Debug)]
pub struct StreamDeckDevicePayload {
pub devices: NonCryptoHashSet<StreamDeckDeviceId>,
}
impl Display for StreamDeckDevicePayload {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
write!(f, "Devices: {:?}", &self.devices,)
}
}
#[derive(Eq, PartialEq, Debug)]
pub struct MidiDeviceChangePayload {
pub input_devices: NonCryptoHashSet<MidiInputDeviceId>,
pub output_devices: NonCryptoHashSet<MidiOutputDeviceId>,
}
impl Display for MidiDeviceChangePayload {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
write!(
f,
"Input devices: {:?}, Output devices: {:?}",
&self.input_devices, &self.output_devices
)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,4 @@
use crate::domain::AdditionalLuaMidiSourceScriptInput;
use helgoboss_learn::SourceContext;
pub type RealearnSourceContext<'a> = SourceContext<AdditionalLuaMidiSourceScriptInput<'a>>;
@@ -0,0 +1,99 @@
use crate::domain::UnitId;
use base::hash_util::{NonCryptoHashMap, NonCryptoHashSet};
use hidapi::HidApi;
use serde::{Deserialize, Serialize};
use std::fmt::{Display, Formatter};
use streamdeck::{pids, StreamDeck};
pub struct ProbedStreamDeckDevice {
pub dev: StreamDeckDevice,
pub available: bool,
}
#[derive(Copy, Clone, Debug)]
pub struct StreamDeckDevice {
pub id: StreamDeckDeviceId,
pub name: &'static str,
}
impl StreamDeckDevice {
pub const fn new(vid: u16, pid: u16, name: &'static str) -> Self {
let id = StreamDeckDeviceId { vid, pid };
Self { id, name }
}
}
pub fn probe_stream_deck_devices() -> anyhow::Result<Vec<ProbedStreamDeckDevice>> {
let mut api = HidApi::new()?;
api.refresh_devices()?;
let connected_devs: NonCryptoHashSet<_> = api
.device_list()
.map(|info| StreamDeckDeviceId {
vid: info.vendor_id(),
pid: info.product_id(),
})
.collect();
let probed_devs = SUPPORTED_DEVICES
.iter()
.copied()
.map(|dev| ProbedStreamDeckDevice {
dev,
available: connected_devs.contains(&dev.id),
})
.collect();
Ok(probed_devs)
}
const ELGATO_VENDOR_ID: u16 = 0x0fd9;
const SUPPORTED_DEVICES: &[StreamDeckDevice] = &[
StreamDeckDevice::new(ELGATO_VENDOR_ID, pids::ORIGINAL, "Original"),
StreamDeckDevice::new(ELGATO_VENDOR_ID, pids::ORIGINAL_V2, "Original v2"),
StreamDeckDevice::new(ELGATO_VENDOR_ID, pids::MINI, "Mini"),
StreamDeckDevice::new(ELGATO_VENDOR_ID, pids::XL, "XL"),
StreamDeckDevice::new(ELGATO_VENDOR_ID, pids::MK2, "MK2"),
StreamDeckDevice::new(ELGATO_VENDOR_ID, pids::REVISED_MINI, "Revised Mini"),
];
#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug, Serialize, Deserialize)]
pub struct StreamDeckDeviceId {
/// Vendor ID.
pub vid: u16,
/// Product ID.
pub pid: u16,
// Serial number (for distinguishing between multiple devices of the same type).
// pub serial_number: Option<String>,
}
impl Display for StreamDeckDeviceId {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}/{}", self.vid, self.pid)
}
}
impl StreamDeckDeviceId {
pub fn connect(&self) -> Result<StreamDeck, streamdeck::Error> {
let mut sd = StreamDeck::connect(self.vid, self.pid, None)?;
sd.set_blocking(false)?;
Ok(sd)
}
}
#[derive(Debug, Default)]
pub struct StreamDeckDeviceManager {
device_usage: NonCryptoHashMap<UnitId, StreamDeckDeviceId>,
}
impl StreamDeckDeviceManager {
pub fn register_device_usage(&mut self, unit_id: UnitId, device: Option<StreamDeckDeviceId>) {
if let Some(d) = device {
self.device_usage.insert(unit_id, d);
} else {
self.device_usage.remove(&unit_id);
}
}
pub fn devices_in_use(&self) -> NonCryptoHashSet<StreamDeckDeviceId> {
self.device_usage.values().copied().collect()
}
}
@@ -0,0 +1,180 @@
use crate::domain::StreamDeckDeviceId;
use derivative::Derivative;
use helgoboss_learn::{ControlValue, FeedbackValue, RgbColor, UnitValue};
use helgobox_api::persistence::StreamDeckButtonDesign;
use std::fmt::{Display, Formatter};
use std::hash::{Hash, Hasher};
#[derive(Clone, Eq, PartialEq, Debug)]
pub struct StreamDeckSource {
pub button_index: u32,
pub button_design: StreamDeckButtonDesign,
}
impl StreamDeckSource {
pub fn new(button_index: u32, button_design: StreamDeckButtonDesign) -> Self {
Self {
button_index,
button_design,
}
}
pub fn feedback_address(&self) -> StreamDeckSourceAddress {
StreamDeckSourceAddress {
button_index: self.button_index,
}
}
/// Checks if the given message is directed to the same address as the one of this source.
///
/// Used for:
///
/// - Source takeover (feedback)
pub fn has_same_feedback_address_as_value(
&self,
value: &StreamDeckSourceFeedbackValue,
) -> bool {
self.feedback_address() == value.feedback_address()
}
/// Checks if this and the given source share the same address.
///
/// Used for:
///
/// - Feedback diffing
pub fn has_same_feedback_address_as_source(&self, other: &Self) -> bool {
self.feedback_address() == other.feedback_address()
}
pub fn control(&self, msg: StreamDeckMessage) -> Option<ControlValue> {
if msg.button_index != self.button_index {
return None;
}
let val = if msg.press {
UnitValue::MAX
} else {
UnitValue::MIN
};
Some(ControlValue::AbsoluteContinuous(val))
}
pub fn feedback(
&self,
feedback_value: &FeedbackValue,
) -> Option<StreamDeckSourceFeedbackValue> {
let value = match feedback_value {
FeedbackValue::Off => StreamDeckSourceFeedbackValue {
button_index: self.button_index,
payload: StreamDeckSourceFeedbackPayload::Off,
},
FeedbackValue::Numeric(v) => StreamDeckSourceFeedbackValue {
button_index: self.button_index,
payload: StreamDeckSourceFeedbackPayload::On(StreamDeckSourceFeedbackOnPayload {
button_design: self.button_design.clone(),
background_color: v.style.background_color,
foreground_color: v.style.color,
numeric_value: Some(v.value.to_unit_value()),
text_value: None,
}),
},
FeedbackValue::Textual(v) => StreamDeckSourceFeedbackValue {
button_index: self.button_index,
payload: StreamDeckSourceFeedbackPayload::On(StreamDeckSourceFeedbackOnPayload {
button_design: self.button_design.clone(),
background_color: v.style.background_color,
foreground_color: v.style.color,
numeric_value: None,
text_value: Some(v.text.to_string()),
}),
},
FeedbackValue::Complex(_) => {
// TODO-medium At some point we might want to support complex dynamically generated feedback (by glue section)
return None;
}
};
Some(value)
}
}
impl Display for StreamDeckSource {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "Button {}", self.button_index + 1)
}
}
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
pub struct StreamDeckMessage {
pub button_index: u32,
pub press: bool,
}
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
pub struct QualifiedStreamDeckMessage {
pub dev_id: StreamDeckDeviceId,
pub msg: StreamDeckMessage,
}
impl StreamDeckMessage {
pub fn new(button_index: u32, press: bool) -> Self {
Self {
button_index,
press,
}
}
}
impl Display for StreamDeckMessage {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{} {}", self.button_index, self.press)
}
}
#[derive(Clone, Eq, PartialEq, Hash, Debug)]
pub struct StreamDeckSourceFeedbackValue {
pub button_index: u32,
pub payload: StreamDeckSourceFeedbackPayload,
}
#[derive(Clone, Eq, PartialEq, Hash, Debug)]
pub enum StreamDeckSourceFeedbackPayload {
Off,
On(StreamDeckSourceFeedbackOnPayload),
}
#[derive(Clone, Eq, PartialEq, Debug, Derivative)]
#[derivative(Hash)]
pub struct StreamDeckSourceFeedbackOnPayload {
pub button_design: StreamDeckButtonDesign,
pub background_color: Option<RgbColor>,
pub foreground_color: Option<RgbColor>,
#[derivative(Hash(hash_with = "hash_opt_unit_value_for_change_detection"))]
pub numeric_value: Option<UnitValue>,
pub text_value: Option<String>,
}
fn hash_opt_unit_value_for_change_detection<H>(value: &Option<UnitValue>, state: &mut H)
where
H: Hasher,
{
let raw = value.map(|v| v.get().to_ne_bytes());
raw.hash(state);
}
impl StreamDeckSourceFeedbackValue {
pub fn feedback_address(&self) -> StreamDeckSourceAddress {
StreamDeckSourceAddress {
button_index: self.button_index,
}
}
}
#[derive(Clone, Eq, PartialEq, Hash, Debug)]
pub struct StreamDeckSourceAddress {
pub button_index: u32,
}
#[derive(Clone, PartialEq, Debug)]
pub struct StreamDeckScanResult {
pub message: StreamDeckMessage,
pub dev_id: Option<StreamDeckDeviceId>,
}
@@ -0,0 +1,46 @@
use base::{convert_to_identifier, SmallAsciiString};
use serde_with::{DeserializeFromStr, SerializeDisplay};
use std::str::FromStr;
/// We reduce the number of possible letters in case we want to use tags in the audio thread in
/// future (and therefore need to avoid allocation).
#[derive(
Clone,
Eq,
PartialEq,
Ord,
PartialOrd,
Debug,
Hash,
derive_more::Display,
SerializeDisplay,
DeserializeFromStr,
)]
pub struct Tag(SmallAsciiString);
impl FromStr for Tag {
type Err = &'static str;
fn from_str(text: &str) -> Result<Self, Self::Err> {
let small_ascii_string = convert_to_identifier(text)?;
Ok(Self(small_ascii_string))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
pub fn parse_tags() {
assert_eq!(Tag::from_str("hey").unwrap().to_string(), "hey");
assert_eq!(Tag::from_str("_hey").unwrap().to_string(), "_hey");
assert_eq!(Tag::from_str("HeY").unwrap().to_string(), "hey");
assert_eq!(Tag::from_str("hey_test").unwrap().to_string(), "hey_test");
assert_eq!(
Tag::from_str("1ähey1ätest").unwrap().to_string(),
"hey1test"
);
assert!(Tag::from_str("").is_err());
}
}
@@ -0,0 +1,319 @@
use crate::domain::ui_util::convert_bool_to_unit_value;
use crate::domain::{
format_bool_as_on_off, get_effective_tracks, ActionInvocationType, AdditionalFeedbackEvent,
CompartmentKind, CompoundChangeEvent, ControlContext, ExtendedProcessorContext, HitResponse,
MappingControlContext, RealearnTarget, ReaperTarget, ReaperTargetType, TargetCharacter,
TargetSection, TargetTypeDef, TrackDescriptor, UnresolvedReaperTargetDef, DEFAULT_TARGET,
};
use camino::Utf8Path;
use helgoboss_learn::{AbsoluteValue, ControlType, ControlValue, Fraction, Target, UnitValue};
use helgoboss_midi::{U14, U7};
use helgobox_api::persistence::ActionScope;
use reaper_high::{Action, ActionCharacter, Project, Reaper, Track};
use reaper_medium::{
ActionValueChange, CommandId, Hwnd, MasterTrackBehavior, OpenMediaExplorerMode,
};
use std::borrow::Cow;
use std::convert::TryFrom;
#[derive(Debug)]
pub struct UnresolvedActionTarget {
pub action: Action,
pub scope: ActionScope,
pub invocation_type: ActionInvocationType,
pub track_descriptor: Option<TrackDescriptor>,
}
impl UnresolvedReaperTargetDef for UnresolvedActionTarget {
fn resolve(
&self,
context: ExtendedProcessorContext,
compartment: CompartmentKind,
) -> Result<Vec<ReaperTarget>, &'static str> {
let project = context.context().project_or_current_project();
let resolved_targets = if let Some(td) = &self.track_descriptor {
get_effective_tracks(context, &td.track, compartment)?
.into_iter()
.map(|track| {
ReaperTarget::Action(ActionTarget {
action: self.action.clone(),
scope: self.scope,
invocation_type: self.invocation_type,
project,
track: Some(track),
})
})
.collect()
} else {
vec![ReaperTarget::Action(ActionTarget {
action: self.action.clone(),
scope: self.scope,
invocation_type: self.invocation_type,
project,
track: None,
})]
};
Ok(resolved_targets)
}
fn track_descriptor(&self) -> Option<&TrackDescriptor> {
self.track_descriptor.as_ref()
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct ActionTarget {
pub action: Action,
pub scope: ActionScope,
pub invocation_type: ActionInvocationType,
pub project: Project,
pub track: Option<Track>,
}
impl RealearnTarget for ActionTarget {
fn control_type_and_character(&self, _: ControlContext) -> (ControlType, TargetCharacter) {
match self.invocation_type {
ActionInvocationType::Trigger => (
ControlType::AbsoluteContinuousRetriggerable,
TargetCharacter::Trigger,
),
ActionInvocationType::Absolute14Bit | ActionInvocationType::Absolute7Bit => {
match self.action.character() {
Ok(ActionCharacter::Toggle) => {
(ControlType::AbsoluteContinuous, TargetCharacter::Switch)
}
Ok(ActionCharacter::Trigger) => {
// If the action character is not toggle, it will emit ReaLearn-generated "fake" values,
// because natively, trigger-like REAPER actions don't have the concept of a current value.
// If that fake value is incorrect and the control type is not retriggerable, ReaLearn won't
// fire. Bad. That's why we need to make it retriggerable.
(
ControlType::AbsoluteContinuousRetriggerable,
TargetCharacter::Continuous,
)
}
Err(_) => (
ControlType::AbsoluteContinuousRetriggerable,
TargetCharacter::Continuous,
),
}
}
ActionInvocationType::Relative => (ControlType::Relative, TargetCharacter::Discrete),
}
}
fn open(&self, _: ControlContext) {
// Just open action window
Reaper::get()
.main_section()
.action_by_command_id(CommandId::new(40605))
.invoke_as_trigger(Some(self.project), None)
.expect("built-in action should exist");
}
fn format_value(&self, _: UnitValue, _: ControlContext) -> String {
"".to_owned()
}
fn hit(
&mut self,
value: ControlValue,
_: MappingControlContext,
) -> Result<HitResponse, &'static str> {
if let Some(track) = &self.track {
if !track.is_selected()
|| self
.project
.selected_track_count(MasterTrackBehavior::IncludeMasterTrack)
> 1
{
track.select_exclusively();
}
}
let response = match value {
ControlValue::AbsoluteContinuous(v) => match self.invocation_type {
ActionInvocationType::Trigger => {
if v.is_zero() {
HitResponse::ignored()
} else {
self.invoke_absolute_with_unit_value(v, false)?;
HitResponse::processed_with_effect()
}
}
ActionInvocationType::Absolute14Bit => {
self.invoke_absolute_with_unit_value(v, false)?;
HitResponse::processed_with_effect()
}
ActionInvocationType::Absolute7Bit => {
self.invoke_absolute_with_unit_value(v, true)?;
HitResponse::processed_with_effect()
}
ActionInvocationType::Relative => {
return Err("relative invocation type can't take absolute values");
}
},
ControlValue::AbsoluteDiscrete(f) => match self.invocation_type {
ActionInvocationType::Trigger => {
if f.is_zero() {
HitResponse::ignored()
} else {
self.invoke_absolute_with_fraction(f, false)?;
HitResponse::processed_with_effect()
}
}
ActionInvocationType::Absolute14Bit => {
self.invoke_absolute_with_fraction(f, false)?;
HitResponse::processed_with_effect()
}
ActionInvocationType::Absolute7Bit => {
self.invoke_absolute_with_fraction(f, true)?;
HitResponse::processed_with_effect()
}
ActionInvocationType::Relative => {
return Err("relative invocation type can't take absolute values");
}
},
ControlValue::RelativeDiscrete(i) => {
if let ActionInvocationType::Relative = self.invocation_type {
self.action.invoke_relative(
i.get(),
Some(self.project),
self.get_context_window(),
)?;
HitResponse::processed_with_effect()
} else {
return Err("relative values need relative invocation type");
}
}
ControlValue::RelativeContinuous(i) => {
if let ActionInvocationType::Relative = self.invocation_type {
let i = i.to_discrete_increment();
self.action.invoke_relative(
i.get(),
Some(self.project),
self.get_context_window(),
)?;
HitResponse::processed_with_effect()
} else {
return Err("relative values need relative invocation type");
}
}
};
Ok(response)
}
fn is_available(&self, _: ControlContext) -> bool {
self.action.is_available()
}
fn process_change_event(
&self,
evt: CompoundChangeEvent,
_: ControlContext,
) -> (bool, Option<AbsoluteValue>) {
match evt {
// We can't provide a value from the event itself because the action hooks don't
// pass values.
CompoundChangeEvent::Additional(AdditionalFeedbackEvent::ActionInvoked(e))
if Ok(e.command_id) == self.action.command_id() =>
{
(true, None)
}
_ => (false, None),
}
}
fn text_value(&self, _: ControlContext) -> Option<Cow<'static, str>> {
Some(format_bool_as_on_off(self.action.is_on().ok()??).into())
}
fn reaper_target_type(&self) -> Option<ReaperTargetType> {
Some(ReaperTargetType::Action)
}
}
impl<'a> Target<'a> for ActionTarget {
type Context = ControlContext<'a>;
fn current_value(&self, _: Self::Context) -> Option<AbsoluteValue> {
let val = if let Some(state) = self.action.is_on().ok()? {
// Toggle action: Return toggle state as 0 or 1.
convert_bool_to_unit_value(state)
} else if self.invocation_type.is_absolute() {
// Absolute non-toggle action. Try to return current absolute "fake" value if this is a
// MIDI CC/mousewheel action.
if let Some(value) = self.action.normalized_value() {
UnitValue::new(value)
} else {
UnitValue::MIN
}
} else {
// Relative or trigger. Not returning any "fake" value here because this will let the
// glue section make wrong assumptions, especially for "Trigger" invocation mode.
return None;
};
Some(AbsoluteValue::Continuous(val))
}
fn control_type(&self, context: Self::Context) -> ControlType {
self.control_type_and_character(context).0
}
}
impl ActionTarget {
fn invoke_absolute_with_fraction(
&self,
f: Fraction,
enforce_7_bit: bool,
) -> Result<(), &'static str> {
let value_change = if enforce_7_bit {
let v = U7::try_from(f.actual()).map_err(|_| "couldn't convert to U7")?;
ActionValueChange::AbsoluteLowRes(v)
} else {
let v = U14::try_from(f.actual()).map_err(|_| "couldn't convert to U14")?;
ActionValueChange::AbsoluteHighRes(v)
};
self.action.invoke_directly(
value_change,
self.get_context_window(),
self.project.context(),
)?;
Ok(())
}
fn invoke_absolute_with_unit_value(
&self,
v: UnitValue,
enforce_7_bit: bool,
) -> Result<(), &'static str> {
self.action.invoke_absolute(
v.get(),
Some(self.project),
enforce_7_bit,
self.get_context_window(),
)?;
Ok(())
}
fn get_context_window(&self) -> Option<Hwnd> {
match self.scope {
ActionScope::Main => None,
ActionScope::ActiveMidiEditor | ActionScope::ActiveMidiEventListEditor => {
Reaper::get().medium_reaper().midi_editor_get_active()
}
ActionScope::MediaExplorer => Reaper::get()
.medium_reaper()
.open_media_explorer(Utf8Path::new(""), OpenMediaExplorerMode::Select),
}
}
}
pub const ACTION_TARGET: TargetTypeDef = TargetTypeDef {
section: TargetSection::Project,
name: "Invoke REAPER action",
short_name: "Action",
hint: "Limited feedback only",
supports_track: true,
if_so_supports_track_must_be_selected: false,
..DEFAULT_TARGET
};
@@ -0,0 +1,135 @@
use crate::domain::{
all_track_fx_enable_unit_value, change_track_prop, format_value_as_on_off,
get_control_type_and_character_for_track_exclusivity, get_effective_tracks, CompartmentKind,
ControlContext, ExtendedProcessorContext, FeedbackResolution, HitResponse,
MappingControlContext, RealearnTarget, ReaperTarget, ReaperTargetType, TargetCharacter,
TargetSection, TargetTypeDef, TrackDescriptor, TrackExclusivity, UnresolvedReaperTargetDef,
AUTOMATIC_FEEDBACK_VIA_POLLING_ONLY, DEFAULT_TARGET,
};
use helgoboss_learn::{AbsoluteValue, ControlType, ControlValue, Target, UnitValue};
use reaper_high::{Project, Track};
use std::borrow::Cow;
#[derive(Debug)]
pub struct UnresolvedAllTrackFxEnableTarget {
pub track_descriptor: TrackDescriptor,
pub exclusivity: TrackExclusivity,
pub poll_for_feedback: bool,
}
impl UnresolvedReaperTargetDef for UnresolvedAllTrackFxEnableTarget {
fn resolve(
&self,
context: ExtendedProcessorContext,
compartment: CompartmentKind,
) -> Result<Vec<ReaperTarget>, &'static str> {
Ok(
get_effective_tracks(context, &self.track_descriptor.track, compartment)?
.into_iter()
.map(|track| {
ReaperTarget::AllTrackFxEnable(AllTrackFxEnableTarget {
track,
exclusivity: self.exclusivity,
poll_for_feedback: self.poll_for_feedback,
})
})
.collect(),
)
}
fn feedback_resolution(&self) -> Option<FeedbackResolution> {
if self.poll_for_feedback {
Some(FeedbackResolution::High)
} else {
None
}
}
fn track_descriptor(&self) -> Option<&TrackDescriptor> {
Some(&self.track_descriptor)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AllTrackFxEnableTarget {
pub track: Track,
pub exclusivity: TrackExclusivity,
pub poll_for_feedback: bool,
}
impl RealearnTarget for AllTrackFxEnableTarget {
fn control_type_and_character(&self, _: ControlContext) -> (ControlType, TargetCharacter) {
get_control_type_and_character_for_track_exclusivity(self.exclusivity)
}
fn format_value(&self, value: UnitValue, _: ControlContext) -> String {
format_value_as_on_off(value).to_string()
}
fn hit(
&mut self,
value: ControlValue,
_: MappingControlContext,
) -> Result<HitResponse, &'static str> {
change_track_prop(
&self.track,
self.exclusivity,
value.to_unit_value()?,
|t| t.enable_fx(),
|t| t.disable_fx(),
);
Ok(HitResponse::processed_with_effect())
}
fn is_available(&self, _: ControlContext) -> bool {
self.track.is_available()
}
fn project(&self) -> Option<Project> {
Some(self.track.project())
}
fn track(&self) -> Option<&Track> {
Some(&self.track)
}
fn track_exclusivity(&self) -> Option<TrackExclusivity> {
Some(self.exclusivity)
}
fn supports_automatic_feedback(&self) -> bool {
self.poll_for_feedback
}
fn text_value(&self, context: ControlContext) -> Option<Cow<'static, str>> {
Some(format_value_as_on_off(self.current_value(context)?.to_unit_value()).into())
}
fn reaper_target_type(&self) -> Option<ReaperTargetType> {
Some(ReaperTargetType::AllTrackFxEnable)
}
}
impl<'a> Target<'a> for AllTrackFxEnableTarget {
type Context = ControlContext<'a>;
fn current_value(&self, _: Self::Context) -> Option<AbsoluteValue> {
let val = all_track_fx_enable_unit_value(self.track.fx_is_enabled());
Some(AbsoluteValue::Continuous(val))
}
fn control_type(&self, context: Self::Context) -> ControlType {
self.control_type_and_character(context).0
}
}
pub const ALL_TRACK_FX_ENABLE_TARGET: TargetTypeDef = TargetTypeDef {
section: TargetSection::Track,
name: "Enable/disable all FX",
short_name: "Enable/disable all track FX",
hint: AUTOMATIC_FEEDBACK_VIA_POLLING_ONLY,
supports_poll_for_feedback: true,
supports_track: true,
supports_track_exclusivity: true,
..DEFAULT_TARGET
};
@@ -0,0 +1,188 @@
use crate::domain::{
format_value_as_on_off, CompartmentKind, CompoundChangeEvent, ControlContext,
ExtendedProcessorContext, HitResponse, MappingControlContext, RealearnTarget, ReaperTarget,
ReaperTargetType, TargetCharacter, TargetSection, TargetTypeDef, UnresolvedReaperTargetDef,
DEFAULT_TARGET,
};
use derive_more::Display;
use helgoboss_learn::{AbsoluteValue, ControlType, ControlValue, Target, UnitValue};
use num_enum::{IntoPrimitive, TryFromPrimitive};
use reaper_high::{ChangeEvent, GroupingBehavior, Project};
use reaper_medium::GangBehavior;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use strum::EnumIter;
#[derive(Debug)]
pub struct UnresolvedAnyOnTarget {
pub parameter: AnyOnParameter,
}
impl UnresolvedReaperTargetDef for UnresolvedAnyOnTarget {
fn resolve(
&self,
context: ExtendedProcessorContext,
_: CompartmentKind,
) -> Result<Vec<ReaperTarget>, &'static str> {
Ok(vec![ReaperTarget::AnyOn(AnyOnTarget {
project: context.context().project_or_current_project(),
parameter: self.parameter,
})])
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AnyOnTarget {
pub project: Project,
pub parameter: AnyOnParameter,
}
impl RealearnTarget for AnyOnTarget {
fn control_type_and_character(&self, _: ControlContext) -> (ControlType, TargetCharacter) {
// Retriggerable because the logic of this target is unusual: Pressing a button (= receiving
// on = 100%) is supposed to switch everything to *off*. So the desired target value doesn't
// correspond to the incoming value.
(
ControlType::AbsoluteContinuousRetriggerable,
TargetCharacter::Switch,
)
}
fn format_value(&self, value: UnitValue, _: ControlContext) -> String {
format_value_as_on_off(value).to_string()
}
fn hit(
&mut self,
value: ControlValue,
_: MappingControlContext,
) -> Result<HitResponse, &'static str> {
if !value.is_on() {
return Ok(HitResponse::ignored());
}
for t in self.project.tracks() {
use AnyOnParameter::*;
match self.parameter {
TrackSolo => t.unsolo(GangBehavior::DenyGang, GroupingBehavior::PreventGrouping),
TrackMute => t.unmute(GangBehavior::DenyGang, GroupingBehavior::PreventGrouping),
TrackArm => t.disarm(
false,
GangBehavior::DenyGang,
GroupingBehavior::PreventGrouping,
),
TrackSelection => t.unselect(),
}
}
Ok(HitResponse::processed_with_effect())
}
fn is_available(&self, _: ControlContext) -> bool {
true
}
fn project(&self) -> Option<Project> {
Some(self.project)
}
fn process_change_event(
&self,
evt: CompoundChangeEvent,
_: ControlContext,
) -> (bool, Option<AbsoluteValue>) {
use AnyOnParameter::*;
use CompoundChangeEvent::*;
match evt {
Reaper(ChangeEvent::TrackSoloChanged(e))
if self.parameter == TrackSolo && e.track.project() == self.project =>
{
(true, None)
}
Reaper(ChangeEvent::TrackMuteChanged(e))
if self.parameter == TrackMute && e.track.project() == self.project =>
{
(true, None)
}
Reaper(ChangeEvent::TrackArmChanged(e))
if self.parameter == TrackArm && e.track.project() == self.project =>
{
(true, None)
}
Reaper(ChangeEvent::TrackSelectedChanged(e))
if self.parameter == TrackSelection && e.track.project() == self.project =>
{
(true, None)
}
_ => (false, None),
}
}
fn text_value(&self, context: ControlContext) -> Option<Cow<'static, str>> {
Some(format_value_as_on_off(self.current_value(context)?.to_unit_value()).into())
}
fn reaper_target_type(&self) -> Option<ReaperTargetType> {
Some(ReaperTargetType::AnyOn)
}
}
impl<'a> Target<'a> for AnyOnTarget {
type Context = ControlContext<'a>;
fn current_value(&self, _: Self::Context) -> Option<AbsoluteValue> {
use AnyOnParameter::*;
let on = match self.parameter {
TrackSolo => self.project.any_solo(),
TrackMute => self.project.tracks().any(|t| t.is_muted()),
TrackArm => self.project.tracks().any(|t| t.is_armed(false)),
TrackSelection => self.project.tracks().any(|t| t.is_selected()),
};
Some(AbsoluteValue::from_bool(on))
}
fn control_type(&self, context: Self::Context) -> ControlType {
self.control_type_and_character(context).0
}
}
#[derive(
Clone,
Copy,
Debug,
PartialEq,
Eq,
Serialize,
Deserialize,
EnumIter,
TryFromPrimitive,
IntoPrimitive,
Display,
)]
#[repr(usize)]
#[allow(clippy::enum_variant_names)]
pub enum AnyOnParameter {
#[serde(rename = "track-solo")]
#[display(fmt = "Track solo")]
TrackSolo,
#[serde(rename = "track-mute")]
#[display(fmt = "Track mute")]
TrackMute,
#[serde(rename = "track-arm")]
#[display(fmt = "Track arm")]
TrackArm,
#[serde(rename = "track-selection")]
#[display(fmt = "Track selection")]
TrackSelection,
}
impl Default for AnyOnParameter {
fn default() -> Self {
Self::TrackSolo
}
}
pub const ANY_ON_TARGET: TargetTypeDef = TargetTypeDef {
section: TargetSection::Project,
name: "Any on (solo/mute/...)",
short_name: "Any on",
..DEFAULT_TARGET
};
@@ -0,0 +1,112 @@
use crate::domain::{
format_value_as_on_off, global_automation_mode_override_unit_value, CompartmentKind,
CompoundChangeEvent, ControlContext, ExtendedProcessorContext, HitResponse,
MappingControlContext, RealearnTarget, ReaperTarget, ReaperTargetType, TargetCharacter,
TargetSection, TargetTypeDef, UnresolvedReaperTargetDef, DEFAULT_TARGET,
};
use helgoboss_learn::{AbsoluteValue, ControlType, ControlValue, Target, UnitValue};
use reaper_high::{ChangeEvent, Reaper};
use reaper_medium::GlobalAutomationModeOverride;
use std::borrow::Cow;
#[derive(Debug)]
pub struct UnresolvedAutomationModeOverrideTarget {
pub mode_override: Option<GlobalAutomationModeOverride>,
}
impl UnresolvedReaperTargetDef for UnresolvedAutomationModeOverrideTarget {
fn resolve(
&self,
_: ExtendedProcessorContext,
_: CompartmentKind,
) -> Result<Vec<ReaperTarget>, &'static str> {
Ok(vec![ReaperTarget::AutomationModeOverride(
AutomationModeOverrideTarget {
mode_override: self.mode_override,
},
)])
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AutomationModeOverrideTarget {
pub mode_override: Option<GlobalAutomationModeOverride>,
}
impl RealearnTarget for AutomationModeOverrideTarget {
fn control_type_and_character(&self, _: ControlContext) -> (ControlType, TargetCharacter) {
// Retriggerable because of #277
(
ControlType::AbsoluteContinuousRetriggerable,
TargetCharacter::Switch,
)
}
fn format_value(&self, value: UnitValue, _: ControlContext) -> String {
format_value_as_on_off(value).to_string()
}
fn hit(
&mut self,
value: ControlValue,
_: MappingControlContext,
) -> Result<HitResponse, &'static str> {
if value.to_unit_value()?.is_zero() {
Reaper::get().set_global_automation_override(None);
} else {
Reaper::get().set_global_automation_override(self.mode_override);
}
Ok(HitResponse::processed_with_effect())
}
fn is_available(&self, _: ControlContext) -> bool {
true
}
fn process_change_event(
&self,
evt: CompoundChangeEvent,
_: ControlContext,
) -> (bool, Option<AbsoluteValue>) {
match evt {
CompoundChangeEvent::Reaper(ChangeEvent::GlobalAutomationOverrideChanged(e)) => (
true,
Some(AbsoluteValue::Continuous(
global_automation_mode_override_unit_value(self.mode_override, e.new_value),
)),
),
_ => (false, None),
}
}
fn text_value(&self, context: ControlContext) -> Option<Cow<'static, str>> {
Some(format_value_as_on_off(self.current_value(context)?.to_unit_value()).into())
}
fn reaper_target_type(&self) -> Option<ReaperTargetType> {
Some(ReaperTargetType::AutomationModeOverride)
}
}
impl<'a> Target<'a> for AutomationModeOverrideTarget {
type Context = ControlContext<'a>;
fn current_value(&self, _: Self::Context) -> Option<AbsoluteValue> {
let value = global_automation_mode_override_unit_value(
self.mode_override,
Reaper::get().global_automation_override(),
);
Some(AbsoluteValue::Continuous(value))
}
fn control_type(&self, context: Self::Context) -> ControlType {
self.control_type_and_character(context).0
}
}
pub const AUTOMATION_MODE_OVERRIDE_TARGET: TargetTypeDef = TargetTypeDef {
section: TargetSection::Global,
name: "Set automation mode override",
short_name: "Automation override",
..DEFAULT_TARGET
};
@@ -0,0 +1,263 @@
use crate::domain::{
convert_count_to_step_size, convert_unit_value_to_fx_index, get_fx_chains, get_fx_name,
shown_fx_unit_value, CompartmentKind, CompoundChangeEvent, ControlContext,
ExtendedProcessorContext, FxDisplayType, HitResponse, MappingControlContext, RealearnTarget,
ReaperTarget, ReaperTargetType, TargetCharacter, TargetSection, TargetTypeDef, TrackDescriptor,
UnresolvedReaperTargetDef, DEFAULT_TARGET,
};
use helgoboss_learn::{
AbsoluteValue, ControlType, ControlValue, Fraction, NumericValue, Target, UnitValue,
};
use reaper_high::{ChangeEvent, Fx, FxChain, Project, Track};
use reaper_medium::FxChainVisibility;
use std::borrow::Cow;
#[derive(Debug)]
pub struct UnresolvedBrowseFxsTarget {
pub track_descriptor: TrackDescriptor,
pub is_input_fx: bool,
pub display_type: FxDisplayType,
}
impl UnresolvedReaperTargetDef for UnresolvedBrowseFxsTarget {
fn resolve(
&self,
context: ExtendedProcessorContext,
compartment: CompartmentKind,
) -> Result<Vec<ReaperTarget>, &'static str> {
let fx_chains = get_fx_chains(
context,
&self.track_descriptor.track,
self.is_input_fx,
compartment,
)?;
let targets = fx_chains
.into_iter()
.map(|fx_chain| {
ReaperTarget::BrowseFxs(BrowseFxsTarget {
fx_chain,
display_type: self.display_type,
})
})
.collect();
Ok(targets)
}
fn track_descriptor(&self) -> Option<&TrackDescriptor> {
Some(&self.track_descriptor)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BrowseFxsTarget {
pub fx_chain: FxChain,
pub display_type: FxDisplayType,
}
impl RealearnTarget for BrowseFxsTarget {
fn control_type_and_character(&self, _: ControlContext) -> (ControlType, TargetCharacter) {
// `+ 1` because "<No FX>" is also a possible value.
(
ControlType::AbsoluteDiscrete {
atomic_step_size: convert_count_to_step_size(self.fx_chain.fx_count() + 1),
is_retriggerable: false,
},
TargetCharacter::Discrete,
)
}
fn parse_as_value(
&self,
text: &str,
context: ControlContext,
) -> Result<UnitValue, &'static str> {
self.parse_value_from_discrete_value(text, context)
}
fn parse_as_step_size(
&self,
text: &str,
context: ControlContext,
) -> Result<UnitValue, &'static str> {
self.parse_value_from_discrete_value(text, context)
}
fn convert_unit_value_to_discrete_value(
&self,
input: UnitValue,
_: ControlContext,
) -> Result<u32, &'static str> {
let value = convert_unit_value_to_fx_index(&self.fx_chain, input)
.map(|i| i + 1)
.unwrap_or(0);
Ok(value)
}
fn format_value(&self, value: UnitValue, _: ControlContext) -> String {
match convert_unit_value_to_fx_index(&self.fx_chain, value) {
None => "<No FX>".to_string(),
Some(i) => (i + 1).to_string(),
}
}
fn hit(
&mut self,
value: ControlValue,
_: MappingControlContext,
) -> Result<HitResponse, &'static str> {
let fx_index = match value.to_absolute_value()? {
AbsoluteValue::Continuous(v) => convert_unit_value_to_fx_index(&self.fx_chain, v),
AbsoluteValue::Discrete(f) => {
if f.actual() == 0 {
None
} else {
Some(f.actual() - 1)
}
}
};
use FxDisplayType::*;
match fx_index {
None => match self.display_type {
FloatingWindow => {
self.fx_chain.hide_all_floating_windows();
}
Chain => {
self.fx_chain.hide()?;
}
},
Some(fx_index) => match self.display_type {
FloatingWindow => {
for (i, fx) in self.fx_chain.index_based_fxs().enumerate() {
if i == fx_index as usize {
fx.show_in_floating_window()?;
} else {
fx.hide_floating_window()?;
}
}
}
Chain => {
let fx = self
.fx_chain
.index_based_fx_by_index(fx_index)
.ok_or("FX not available")?;
fx.show_in_chain()?;
}
},
}
Ok(HitResponse::processed_with_effect())
}
fn is_available(&self, _: ControlContext) -> bool {
self.fx_chain.is_available()
}
fn project(&self) -> Option<Project> {
self.fx_chain.project()
}
fn track(&self) -> Option<&Track> {
self.fx_chain.track()
}
fn process_change_event(
&self,
evt: CompoundChangeEvent,
_: ControlContext,
) -> (bool, Option<AbsoluteValue>) {
use CompoundChangeEvent::*;
match evt {
Reaper(ChangeEvent::FxOpened(e)) if e.fx.chain() == &self.fx_chain => (true, None),
Reaper(ChangeEvent::FxClosed(e)) if e.fx.chain() == &self.fx_chain => (true, None),
_ => (false, None),
}
}
fn convert_discrete_value_to_unit_value(
&self,
value: u32,
_: ControlContext,
) -> Result<UnitValue, &'static str> {
let index = if value == 0 { None } else { Some(value - 1) };
Ok(shown_fx_unit_value(&self.fx_chain, index))
}
fn text_value(&self, _: ControlContext) -> Option<Cow<'static, str>> {
Some(get_fx_name(&self.current_fx()?).into())
}
fn numeric_value(&self, _: ControlContext) -> Option<NumericValue> {
let index = self.current_fx_index()?;
Some(NumericValue::Discrete(index as i32 + 1))
}
fn reaper_target_type(&self) -> Option<ReaperTargetType> {
Some(ReaperTargetType::BrowseFxs)
}
}
impl BrowseFxsTarget {
fn current_fx(&self) -> Option<Fx> {
use FxDisplayType::*;
match self.display_type {
FloatingWindow => self
.fx_chain
.index_based_fxs()
.find(|fx| fx.floating_window().is_some()),
Chain => {
use FxChainVisibility::*;
match self.fx_chain.visibility() {
Hidden | Visible(None) | Unknown(_) => None,
Visible(Some(i)) => Some(self.fx_chain.fx_by_index_untracked(i)),
}
}
}
}
fn current_fx_index(&self) -> Option<u32> {
use FxDisplayType::*;
match self.display_type {
FloatingWindow => self
.fx_chain
.index_based_fxs()
.position(|fx| fx.floating_window().is_some())
.map(|i| i as u32),
Chain => {
use FxChainVisibility::*;
match self.fx_chain.visibility() {
Hidden | Visible(None) | Unknown(_) => None,
Visible(Some(i)) => Some(i),
}
}
}
}
}
impl<'a> Target<'a> for BrowseFxsTarget {
type Context = ControlContext<'a>;
fn current_value(&self, _: Self::Context) -> Option<AbsoluteValue> {
let fx_count = self.fx_chain.fx_count();
// Because we count "<No FX>" as a possible value, this is equal.
let max_value = fx_count;
let fx_index = self.current_fx_index();
let actual_value = fx_index.map(|i| i + 1).unwrap_or(0);
Some(AbsoluteValue::Discrete(Fraction::new(
actual_value,
max_value,
)))
}
fn control_type(&self, context: Self::Context) -> ControlType {
self.control_type_and_character(context).0
}
}
pub const BROWSE_FXS_TARGET: TargetTypeDef = TargetTypeDef {
section: TargetSection::FxChain,
name: "Browse FXs",
short_name: "Browse FXs",
supports_track: true,
supports_fx_chain: true,
supports_fx_display_type: true,
..DEFAULT_TARGET
};
@@ -0,0 +1,272 @@
use crate::domain::{
convert_count_to_step_size, convert_discrete_to_unit_value, convert_unit_to_discrete_value,
CompartmentKind, CompoundChangeEvent, ControlContext, ControlLogContext,
ExtendedProcessorContext, GroupId, HitInstruction, HitInstructionContext,
HitInstructionResponse, HitResponse, MappingControlContext, MappingId, QualifiedMappingId,
RealearnTarget, ReaperTarget, ReaperTargetType, SimpleExclusivity, TargetCharacter,
TargetSection, TargetTypeDef, UnitEvent, UnresolvedReaperTargetDef, DEFAULT_TARGET,
};
use helgoboss_learn::{
AbsoluteValue, ControlType, ControlValue, Fraction, NumericValue, Target, UnitValue,
};
use std::borrow::Cow;
#[derive(Debug)]
pub struct UnresolvedBrowseGroupTarget {
pub compartment: CompartmentKind,
pub group_id: GroupId,
pub exclusivity: SimpleExclusivity,
}
impl UnresolvedReaperTargetDef for UnresolvedBrowseGroupTarget {
fn resolve(
&self,
_: ExtendedProcessorContext,
_: CompartmentKind,
) -> Result<Vec<ReaperTarget>, &'static str> {
Ok(vec![ReaperTarget::BrowseGroupMappings(
BrowseGroupMappingsTarget {
compartment: self.compartment,
group_id: self.group_id,
exclusivity: self.exclusivity,
},
)])
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BrowseGroupMappingsTarget {
/// This must always correspond to the compartment of the containing mapping, otherwise it will
/// not have any effect when controlling (only when querying the values).
pub compartment: CompartmentKind,
pub group_id: GroupId,
pub exclusivity: SimpleExclusivity,
}
impl BrowseGroupMappingsTarget {
fn count(&self, context: ControlContext) -> u32 {
context
.unit
.borrow()
.get_on_mappings_within_group(self.compartment, self.group_id)
.count() as _
}
}
impl RealearnTarget for BrowseGroupMappingsTarget {
fn control_type_and_character(
&self,
context: ControlContext,
) -> (ControlType, TargetCharacter) {
(
ControlType::AbsoluteDiscrete {
atomic_step_size: {
let count = self.count(context);
convert_count_to_step_size(count)
},
is_retriggerable: false,
},
TargetCharacter::Discrete,
)
}
fn hit(
&mut self,
value: ControlValue,
context: MappingControlContext,
) -> Result<HitResponse, &'static str> {
let value = value.to_absolute_value()?;
let mut instance_state = context.control_context.unit.borrow_mut();
let desired_mapping_id = {
let mapping_ids: Vec<_> = instance_state
.get_on_mappings_within_group(self.compartment, self.group_id)
.collect();
let count = mapping_ids.len();
let desired_index = match value {
AbsoluteValue::Continuous(v) => convert_unit_to_discrete_value(v, count as _),
AbsoluteValue::Discrete(f) => f.actual(),
};
*mapping_ids
.get(desired_index as usize)
.ok_or("mapping index out of bounds")?
};
instance_state.set_active_mapping_within_group(
self.compartment,
self.group_id,
desired_mapping_id,
);
struct BrowseGroupMappingsInstruction {
group_id: GroupId,
exclusivity: SimpleExclusivity,
desired_mapping_id: MappingId,
}
impl HitInstruction for BrowseGroupMappingsInstruction {
fn execute(self: Box<Self>, context: HitInstructionContext) -> HitInstructionResponse {
let mut control_results = vec![];
for m in context.mappings.values_mut() {
let glue = m.mode().settings();
let v = if m.id() == self.desired_mapping_id {
if glue.reverse {
glue.target_value_interval.min_val()
} else {
glue.target_value_interval.max_val()
}
} else if self.exclusivity == SimpleExclusivity::Exclusive
&& m.group_id() == self.group_id
{
if glue.reverse {
glue.target_value_interval.max_val()
} else {
glue.target_value_interval.min_val()
}
} else {
continue;
};
context
.domain_event_handler
.notify_mapping_matched(m.compartment(), m.id());
let res = m.control_from_target_directly(
context.control_context,
context.processor_context,
ControlValue::AbsoluteContinuous(v),
context.basic_settings.target_control_logger(
context.processor_context.control_context.unit,
ControlLogContext::GroupNavigation,
m.qualified_id(),
),
);
control_results.push(res);
}
HitInstructionResponse::CausedEffect(control_results)
}
}
let instruction = BrowseGroupMappingsInstruction {
group_id: self.group_id,
exclusivity: self.exclusivity,
desired_mapping_id,
};
Ok(HitResponse::hit_instruction(Box::new(instruction)))
}
fn parse_as_value(
&self,
text: &str,
context: ControlContext,
) -> Result<UnitValue, &'static str> {
self.parse_value_from_discrete_value(text, context)
}
fn parse_as_step_size(
&self,
text: &str,
context: ControlContext,
) -> Result<UnitValue, &'static str> {
self.parse_value_from_discrete_value(text, context)
}
fn convert_unit_value_to_discrete_value(
&self,
input: UnitValue,
context: ControlContext,
) -> Result<u32, &'static str> {
let count = self.count(context);
Ok(convert_unit_to_discrete_value(input, count))
}
fn convert_discrete_value_to_unit_value(
&self,
value: u32,
context: ControlContext,
) -> Result<UnitValue, &'static str> {
let count = self.count(context);
Ok(convert_discrete_to_unit_value(value, count as _))
}
fn is_available(&self, context: ControlContext) -> bool {
context
.unit
.borrow()
.get_on_mappings_within_group(self.compartment, self.group_id)
.count()
> 0
}
fn process_change_event(
&self,
evt: CompoundChangeEvent,
_: ControlContext,
) -> (bool, Option<AbsoluteValue>) {
match evt {
CompoundChangeEvent::Unit(UnitEvent::ActiveMappingWithinGroup {
compartment,
group_id,
..
}) if *compartment == self.compartment && *group_id == self.group_id => (true, None),
_ => (false, None),
}
}
fn text_value(&self, context: ControlContext) -> Option<Cow<'static, str>> {
let (mapping_id, _) = self.current_mapping_with_position(context)?;
let instance_state = context.unit.borrow();
let info = instance_state
.get_mapping_info(QualifiedMappingId::new(self.compartment, mapping_id))?;
Some(info.name.clone().into())
}
fn numeric_value(&self, context: ControlContext) -> Option<NumericValue> {
let (_, fraction) = self.current_mapping_with_position(context)?;
Some(NumericValue::Discrete(fraction.actual() as i32 + 1))
}
fn reaper_target_type(&self) -> Option<ReaperTargetType> {
Some(ReaperTargetType::BrowseGroup)
}
}
impl BrowseGroupMappingsTarget {
fn current_mapping_with_position(
&self,
context: ControlContext,
) -> Option<(MappingId, Fraction)> {
let instance_state = context.unit.borrow();
if let Some(mapping_id) =
instance_state.get_active_mapping_within_group(self.compartment, self.group_id)
{
let mapping_ids: Vec<_> = instance_state
.get_on_mappings_within_group(self.compartment, self.group_id)
.collect();
if !mapping_ids.is_empty() {
let max_value = mapping_ids.len() - 1;
if let Some(index) = mapping_ids.iter().position(|id| *id == mapping_id) {
return Some((mapping_id, Fraction::new(index as _, max_value as _)));
}
}
}
None
}
}
impl<'a> Target<'a> for BrowseGroupMappingsTarget {
type Context = ControlContext<'a>;
fn current_value(&self, context: ControlContext) -> Option<AbsoluteValue> {
let fraction = self
.current_mapping_with_position(context)
.map(|(_, f)| f)
.unwrap_or(Fraction::MIN);
Some(AbsoluteValue::Discrete(fraction))
}
fn control_type(&self, context: Self::Context) -> ControlType {
self.control_type_and_character(context).0
}
}
pub const BROWSE_GROUP_MAPPINGS_TARGET: TargetTypeDef = TargetTypeDef {
section: TargetSection::ReaLearn,
name: "Browse group mappings",
short_name: "Browse group mappings",
supports_exclusivity: true,
..DEFAULT_TARGET
};
@@ -0,0 +1,286 @@
use crate::domain::{
convert_count_to_step_size, convert_discrete_to_unit_value_with_none,
convert_unit_to_discrete_value_with_none, CompartmentKind, CompoundChangeEvent, ControlContext,
ExtendedProcessorContext, HitResponse, InstanceStateChanged, MappingControlContext,
PotStateChangedEvent, RealearnTarget, ReaperTarget, ReaperTargetType, TargetCharacter,
TargetSection, TargetTypeDef, UnresolvedReaperTargetDef, DEFAULT_TARGET,
};
use base::blocking_lock_arc;
use helgoboss_learn::{
AbsoluteValue, ControlType, ControlValue, Fraction, NumericValue, PropValue, Target, UnitValue,
};
use helgobox_api::persistence::PotFilterKind;
use pot::{Debounce, FilterItemId};
use pot::{FilterItem, RuntimePotUnit};
use std::borrow::Cow;
#[derive(Debug)]
pub struct UnresolvedBrowsePotFilterItemsTarget {
pub settings: PotFilterItemsTargetSettings,
}
#[derive(Clone, Eq, PartialEq, Debug)]
pub struct PotFilterItemsTargetSettings {
pub kind: PotFilterKind,
}
impl UnresolvedReaperTargetDef for UnresolvedBrowsePotFilterItemsTarget {
fn resolve(
&self,
_: ExtendedProcessorContext,
_: CompartmentKind,
) -> Result<Vec<ReaperTarget>, &'static str> {
Ok(vec![ReaperTarget::BrowsePotFilterItems(
BrowsePotFilterItemsTarget {
settings: self.settings.clone(),
},
)])
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BrowsePotFilterItemsTarget {
pub settings: PotFilterItemsTargetSettings,
}
impl RealearnTarget for BrowsePotFilterItemsTarget {
fn control_type_and_character(
&self,
context: ControlContext,
) -> (ControlType, TargetCharacter) {
// `+ 1` because "<None>" is also a possible value.
let mut instance_state = context.instance().borrow_mut();
let pot_unit = match instance_state.pot_unit() {
Ok(u) => u,
Err(_) => return (ControlType::AbsoluteContinuous, TargetCharacter::Continuous),
};
let pot_unit = blocking_lock_arc(&pot_unit, "PotUnit from BrowsePotFilterItemsTarget 1");
let count = self.item_count(&pot_unit) + 1;
let atomic_step_size = convert_count_to_step_size(count);
(
ControlType::AbsoluteDiscrete {
atomic_step_size,
is_retriggerable: false,
},
TargetCharacter::Discrete,
)
}
fn parse_as_value(
&self,
text: &str,
context: ControlContext,
) -> Result<UnitValue, &'static str> {
self.parse_value_from_discrete_value(text, context)
}
fn parse_as_step_size(
&self,
text: &str,
context: ControlContext,
) -> Result<UnitValue, &'static str> {
self.parse_value_from_discrete_value(text, context)
}
fn convert_unit_value_to_discrete_value(
&self,
value: UnitValue,
context: ControlContext,
) -> Result<u32, &'static str> {
let mut instance_state = context.instance().borrow_mut();
let pot_unit = instance_state.pot_unit()?;
let pot_unit = blocking_lock_arc(&pot_unit, "PotUnit from BrowsePotFilterItemsTarget 2");
let value = self
.convert_unit_value_to_item_index(&pot_unit, value)
.map(|i| i + 1)
.unwrap_or(0);
Ok(value)
}
fn hit(
&mut self,
value: ControlValue,
context: MappingControlContext,
) -> Result<HitResponse, &'static str> {
let mut instance_state = context.control_context.instance().borrow_mut();
let shared_pot_unit = instance_state.pot_unit()?;
let mut pot_unit = blocking_lock_arc(
&shared_pot_unit,
"PotUnit from BrowsePotFilterItemsTarget hit",
);
let item_index = self.convert_unit_value_to_item_index(&pot_unit, value.to_unit_value()?);
let item_id = match item_index {
None => None,
Some(i) => {
let id = pot_unit
.find_filter_item_id_at_index(self.settings.kind, i)
.ok_or("no filter item found for that index")?;
Some(id)
}
};
pot_unit.set_filter(
self.settings.kind,
item_id,
shared_pot_unit.clone(),
Debounce::Yes,
);
Ok(HitResponse::processed_with_effect())
}
fn is_available(&self, context: ControlContext) -> bool {
let mut instance_state = context.instance().borrow_mut();
let Ok(shared_pot_unit) = instance_state.pot_unit() else {
return false;
};
let pot_unit = blocking_lock_arc(
&shared_pot_unit,
"PotUnit from BrowsePotFilterItemsTarget is_available",
);
pot_unit.supports_filter_kind(self.settings.kind)
}
fn process_change_event(
&self,
evt: CompoundChangeEvent,
context: ControlContext,
) -> (bool, Option<AbsoluteValue>) {
match evt {
CompoundChangeEvent::Instance(InstanceStateChanged::PotStateChanged(
PotStateChangedEvent::FilterItemChanged { kind, filter: id },
)) if *kind == self.settings.kind => {
let mut instance_state = context.instance().borrow_mut();
let pot_unit = match instance_state.pot_unit() {
Ok(u) => u,
Err(_) => return (false, None),
};
let pot_unit =
blocking_lock_arc(&pot_unit, "PotUnit from BrowsePotFilterItemsTarget 3");
let value = self.convert_item_id_to_absolute_value(&pot_unit, *id);
(true, Some(value))
}
CompoundChangeEvent::Instance(InstanceStateChanged::PotStateChanged(
PotStateChangedEvent::IndexesRebuilt,
)) => (true, None),
_ => (false, None),
}
}
fn convert_discrete_value_to_unit_value(
&self,
value: u32,
context: ControlContext,
) -> Result<UnitValue, &'static str> {
let index = if value == 0 { None } else { Some(value - 1) };
let mut instance_state = context.instance().borrow_mut();
let pot_unit = instance_state.pot_unit()?;
let pot_unit = blocking_lock_arc(&pot_unit, "PotUnit from BrowsePotFilterItemsTarget 4");
let uv = convert_discrete_to_unit_value_with_none(index, self.item_count(&pot_unit));
Ok(uv)
}
fn text_value(&self, context: ControlContext) -> Option<Cow<'static, str>> {
let mut instance_state = context.instance().borrow_mut();
let pot_unit = instance_state.pot_unit().ok()?;
let pot_unit = blocking_lock_arc(&pot_unit, "PotUnit from BrowsePotFilterItemsTarget 5");
let item_id = match self.current_item_id(&pot_unit) {
None => return Some("Any".into()),
Some(id) => id,
};
let item = match self.find_item_by_id(&pot_unit, item_id) {
None => return Some("<Not found>".into()),
Some(p) => p,
};
Some(item.effective_leaf_name().to_string().into())
}
fn numeric_value(&self, context: ControlContext) -> Option<NumericValue> {
let mut instance_state = context.instance().borrow_mut();
let pot_unit = instance_state.pot_unit().ok()?;
let pot_unit = blocking_lock_arc(&pot_unit, "PotUnit from BrowsePotFilterItemsTarget 6");
let item_id = self.current_item_id(&pot_unit)?;
let item_index = self.find_index_of_item(&pot_unit, item_id)?;
Some(NumericValue::Discrete(item_index as i32 + 1))
}
fn reaper_target_type(&self) -> Option<ReaperTargetType> {
Some(ReaperTargetType::BrowsePotFilterItems)
}
fn prop_value(&self, key: &str, context: ControlContext) -> Option<PropValue> {
let mut instance_state = context.instance().borrow_mut();
let pot_unit = instance_state.pot_unit().ok()?;
let pot_unit = blocking_lock_arc(&pot_unit, "PotUnit from BrowsePotFilterItemsTarget 7");
let item_id = self.current_item_id(&pot_unit)?;
let item = self.find_item_by_id(&pot_unit, item_id)?;
match key {
"item.parent.name" => Some(PropValue::Text(item.parent_name?.into())),
"item.name" => Some(PropValue::Text(item.name?.into())),
_ => None,
}
}
}
impl<'a> Target<'a> for BrowsePotFilterItemsTarget {
type Context = ControlContext<'a>;
fn current_value(&self, context: Self::Context) -> Option<AbsoluteValue> {
let mut instance_state = context.instance().borrow_mut();
let pot_unit = instance_state.pot_unit().ok()?;
let pot_unit = blocking_lock_arc(&pot_unit, "PotUnit from BrowsePotFilterItemsTarget 8");
let item_id = self.current_item_id(&pot_unit);
Some(self.convert_item_id_to_absolute_value(&pot_unit, item_id))
}
fn control_type(&self, context: Self::Context) -> ControlType {
self.control_type_and_character(context).0
}
}
impl BrowsePotFilterItemsTarget {
fn convert_item_id_to_absolute_value(
&self,
pot_unit: &RuntimePotUnit,
item_id: Option<FilterItemId>,
) -> AbsoluteValue {
let item_index = item_id.and_then(|id| self.find_index_of_item(pot_unit, id));
let actual = match item_index {
None => 0,
Some(i) => i + 1,
};
let max = self.item_count(pot_unit);
AbsoluteValue::Discrete(Fraction::new(actual, max))
}
fn item_count(&self, pot_unit: &RuntimePotUnit) -> u32 {
pot_unit.count_filter_items(self.settings.kind)
}
fn convert_unit_value_to_item_index(
&self,
pot_unit: &RuntimePotUnit,
value: UnitValue,
) -> Option<u32> {
convert_unit_to_discrete_value_with_none(value, self.item_count(pot_unit))
}
fn current_item_id(&self, pot_unit: &RuntimePotUnit) -> Option<FilterItemId> {
pot_unit.get_filter(self.settings.kind)
}
fn find_item_by_id(&self, pot_unit: &RuntimePotUnit, id: FilterItemId) -> Option<FilterItem> {
pot_unit
.find_filter_item_by_id(self.settings.kind, id)
.cloned()
}
fn find_index_of_item(&self, pot_unit: &RuntimePotUnit, id: FilterItemId) -> Option<u32> {
pot_unit.find_index_of_filter_item(self.settings.kind, id)
}
}
pub const BROWSE_POT_FILTER_ITEMS_TARGET: TargetTypeDef = TargetTypeDef {
section: TargetSection::Pot,
name: "Browse filter items",
short_name: "Browse Pot filter items",
..DEFAULT_TARGET
};
@@ -0,0 +1,244 @@
use crate::domain::{
convert_count_to_step_size, convert_discrete_to_unit_value_with_none,
convert_unit_to_discrete_value_with_none, get_preset_property, CompartmentKind,
CompoundChangeEvent, ControlContext, ExtendedProcessorContext, HitResponse,
InstanceStateChanged, MappingControlContext, PotStateChangedEvent, RealearnTarget,
ReaperTarget, ReaperTargetType, TargetCharacter, TargetSection, TargetTypeDef,
UnresolvedReaperTargetDef, DEFAULT_TARGET,
};
use base::{blocking_lock, blocking_lock_arc};
use helgoboss_learn::{
AbsoluteValue, ControlType, ControlValue, Fraction, NumericValue, PropValue, Target, UnitValue,
};
use pot::{PotPreset, PresetId, RuntimePotUnit};
use std::borrow::Cow;
#[derive(Debug)]
pub struct UnresolvedBrowsePotPresetsTarget {}
impl UnresolvedReaperTargetDef for UnresolvedBrowsePotPresetsTarget {
fn resolve(
&self,
_: ExtendedProcessorContext,
_: CompartmentKind,
) -> Result<Vec<ReaperTarget>, &'static str> {
Ok(vec![ReaperTarget::BrowsePotPresets(
BrowsePotPresetsTarget {},
)])
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BrowsePotPresetsTarget {}
impl RealearnTarget for BrowsePotPresetsTarget {
fn control_type_and_character(
&self,
context: ControlContext,
) -> (ControlType, TargetCharacter) {
let mut instance_state = context.instance().borrow_mut();
// `+ 1` because "<None>" is also a possible value.
let pot_unit = match instance_state.pot_unit() {
Ok(u) => u,
Err(_) => return (ControlType::AbsoluteContinuous, TargetCharacter::Continuous),
};
let pot_unit = blocking_lock_arc(&pot_unit, "PotUnit from BrowsePotFilterItemsTarget 9");
let count = self.preset_count(&pot_unit) + 1;
let atomic_step_size = convert_count_to_step_size(count);
(
ControlType::AbsoluteDiscrete {
atomic_step_size,
is_retriggerable: false,
},
TargetCharacter::Discrete,
)
}
fn parse_as_value(
&self,
text: &str,
context: ControlContext,
) -> Result<UnitValue, &'static str> {
self.parse_value_from_discrete_value(text, context)
}
fn parse_as_step_size(
&self,
text: &str,
context: ControlContext,
) -> Result<UnitValue, &'static str> {
self.parse_value_from_discrete_value(text, context)
}
fn convert_unit_value_to_discrete_value(
&self,
value: UnitValue,
context: ControlContext,
) -> Result<u32, &'static str> {
let mut instance_state = context.instance().borrow_mut();
let pot_unit = instance_state.pot_unit()?;
let pot_unit = blocking_lock_arc(&pot_unit, "PotUnit from BrowsePotPresetsTarget 1");
let value = self
.convert_unit_value_to_preset_index(&pot_unit, value)
.map(|i| i + 1)
.unwrap_or(0);
Ok(value)
}
fn hit(
&mut self,
value: ControlValue,
context: MappingControlContext,
) -> Result<HitResponse, &'static str> {
let mut instance_state = context.control_context.instance().borrow_mut();
let shared_pot_unit = instance_state.pot_unit()?;
let mut pot_unit =
blocking_lock(&*shared_pot_unit, "PotUnit from BrowsePotPresetsTarget 2");
let preset_index =
self.convert_unit_value_to_preset_index(&pot_unit, value.to_unit_value()?);
let preset_id = match preset_index {
None => None,
Some(i) => {
let id = pot_unit
.find_preset_id_at_index(i)
.ok_or("no preset found for that index")?;
Some(id)
}
};
pot_unit.set_preset_id(preset_id);
Ok(HitResponse::processed_with_effect())
}
fn is_available(&self, _: ControlContext) -> bool {
true
}
fn process_change_event(
&self,
evt: CompoundChangeEvent,
context: ControlContext,
) -> (bool, Option<AbsoluteValue>) {
match evt {
CompoundChangeEvent::Instance(InstanceStateChanged::PotStateChanged(
PotStateChangedEvent::PresetChanged { id },
)) => {
let mut instance_state = context.instance().borrow_mut();
let pot_unit = match instance_state.pot_unit() {
Ok(u) => u,
Err(_) => return (false, None),
};
let pot_unit =
blocking_lock_arc(&pot_unit, "PotUnit from BrowsePotPresetsTarget 3");
let value = self.convert_preset_id_to_absolute_value(&pot_unit, *id);
(true, Some(value))
}
CompoundChangeEvent::Instance(InstanceStateChanged::PotStateChanged(
PotStateChangedEvent::IndexesRebuilt,
)) => (true, None),
_ => (false, None),
}
}
fn convert_discrete_value_to_unit_value(
&self,
value: u32,
context: ControlContext,
) -> Result<UnitValue, &'static str> {
let index = if value == 0 { None } else { Some(value - 1) };
let mut instance_state = context.instance().borrow_mut();
let pot_unit = instance_state.pot_unit()?;
let pot_unit = blocking_lock_arc(&pot_unit, "PotUnit from BrowsePotPresetsTarget 4");
let uv = convert_discrete_to_unit_value_with_none(index, self.preset_count(&pot_unit));
Ok(uv)
}
fn text_value(&self, context: ControlContext) -> Option<Cow<'static, str>> {
if let PropValue::Text(text) = self.prop_value("preset.name", context)? {
Some(text)
} else {
None
}
}
fn numeric_value(&self, context: ControlContext) -> Option<NumericValue> {
let mut instance_state = context.instance().borrow_mut();
let pot_unit = instance_state.pot_unit().ok()?;
let pot_unit = blocking_lock_arc(&pot_unit, "PotUnit from BrowsePotPresetsTarget 5");
let preset_index = pot_unit.find_index_of_preset(pot_unit.preset_id()?)?;
Some(NumericValue::Discrete(preset_index as i32 + 1))
}
fn prop_value(&self, key: &str, context: ControlContext) -> Option<PropValue> {
self.with_selected_preset(context, |p| get_preset_property(p?, key))
}
fn reaper_target_type(&self) -> Option<ReaperTargetType> {
Some(ReaperTargetType::BrowsePotPresets)
}
}
impl<'a> Target<'a> for BrowsePotPresetsTarget {
type Context = ControlContext<'a>;
fn current_value(&self, context: Self::Context) -> Option<AbsoluteValue> {
let mut instance_state = context.instance().borrow_mut();
let pot_unit = instance_state.pot_unit().ok()?;
let pot_unit = blocking_lock_arc(&pot_unit, "PotUnit from BrowsePotPresetsTarget 6");
let preset_id = pot_unit.preset_id();
Some(self.convert_preset_id_to_absolute_value(&pot_unit, preset_id))
}
fn control_type(&self, context: Self::Context) -> ControlType {
self.control_type_and_character(context).0
}
}
impl BrowsePotPresetsTarget {
fn convert_preset_id_to_absolute_value(
&self,
pot_unit: &RuntimePotUnit,
preset_id: Option<PresetId>,
) -> AbsoluteValue {
let preset_index = preset_id.and_then(|id| pot_unit.find_index_of_preset(id));
let actual = match preset_index {
None => 0,
Some(i) => i + 1,
};
let max = self.preset_count(pot_unit);
AbsoluteValue::Discrete(Fraction::new(actual, max))
}
fn preset_count(&self, pot_unit: &RuntimePotUnit) -> u32 {
pot_unit.preset_count()
}
fn convert_unit_value_to_preset_index(
&self,
pot_unit: &RuntimePotUnit,
value: UnitValue,
) -> Option<u32> {
convert_unit_to_discrete_value_with_none(value, self.preset_count(pot_unit))
}
fn with_selected_preset<R>(
&self,
context: ControlContext,
f: impl FnOnce(Option<&PotPreset>) -> R,
) -> R {
let mut instance_state = context.instance().borrow_mut();
if let Ok(pot_unit) = instance_state.pot_unit() {
let pot_unit = blocking_lock_arc(&pot_unit, "PotUnit from BrowsePotPresetsTarget 4");
let preset = pot_unit.find_currently_selected_preset();
f(preset.as_ref())
} else {
f(None)
}
}
}
pub const BROWSE_POT_PRESETS_TARGET: TargetTypeDef = TargetTypeDef {
section: TargetSection::Pot,
name: "Browse presets",
short_name: "Browse Pot presets",
..DEFAULT_TARGET
};
@@ -0,0 +1,358 @@
use crate::domain::{
convert_count_to_step_size, convert_discrete_to_unit_value_with_none,
convert_unit_to_discrete_value_with_none, get_reaper_track_area_of_scope,
get_track_by_scoped_index, get_track_name, scoped_track_index, CompartmentKind,
CompoundChangeEvent, ControlContext, ExtendedProcessorContext, HitResponse,
MappingControlContext, RealearnTarget, ReaperTarget, ReaperTargetType, TargetCharacter,
TargetSection, TargetTypeDef, UnresolvedReaperTargetDef, DEFAULT_TARGET,
};
use helgoboss_learn::{
AbsoluteValue, ControlType, ControlValue, Fraction, NumericValue, Target, UnitValue,
};
use helgobox_api::persistence::{BrowseTracksMode, TrackScope};
use reaper_high::{ChangeEvent, Project, Reaper, Track};
use reaper_medium::{CommandId, MasterTrackBehavior};
use std::borrow::Cow;
#[derive(Debug)]
pub struct UnresolvedBrowseTracksTarget {
pub scroll_arrange_view: bool,
pub scroll_mixer: bool,
pub mode: BrowseTracksMode,
}
impl UnresolvedReaperTargetDef for UnresolvedBrowseTracksTarget {
fn resolve(
&self,
context: ExtendedProcessorContext,
_: CompartmentKind,
) -> Result<Vec<ReaperTarget>, &'static str> {
Ok(vec![ReaperTarget::BrowseTracks(BrowseTracksTarget {
project: context.context().project_or_current_project(),
scroll_arrange_view: self.scroll_arrange_view,
scroll_mixer: self.scroll_mixer,
mode: self.mode,
})])
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BrowseTracksTarget {
pub project: Project,
pub scroll_arrange_view: bool,
pub scroll_mixer: bool,
pub mode: BrowseTracksMode,
}
impl RealearnTarget for BrowseTracksTarget {
fn control_type_and_character(&self, _: ControlContext) -> (ControlType, TargetCharacter) {
(
ControlType::AbsoluteDiscrete {
atomic_step_size: self.step_size(),
is_retriggerable: false,
},
TargetCharacter::Discrete,
)
}
fn parse_as_value(
&self,
text: &str,
context: ControlContext,
) -> Result<UnitValue, &'static str> {
self.parse_value_from_discrete_value(text, context)
}
fn parse_as_step_size(
&self,
text: &str,
context: ControlContext,
) -> Result<UnitValue, &'static str> {
self.parse_value_from_discrete_value(text, context)
}
fn convert_unit_value_to_discrete_value(
&self,
input: UnitValue,
_: ControlContext,
) -> Result<u32, &'static str> {
let value = convert_unit_value_to_track_index(self.project, self.mode.scope(), input)
.map(|i| i + 1)
.unwrap_or(0);
Ok(value)
}
fn format_value(&self, value: UnitValue, _: ControlContext) -> String {
match convert_unit_value_to_track_index(self.project, self.mode.scope(), value) {
None => "<Master track>".to_string(),
Some(i) => (i + 1).to_string(),
}
}
fn hit(
&mut self,
value: ControlValue,
_: MappingControlContext,
) -> Result<HitResponse, &'static str> {
let track_index = match value.to_absolute_value()? {
AbsoluteValue::Continuous(v) => {
convert_unit_value_to_track_index(self.project, self.mode.scope(), v)
}
AbsoluteValue::Discrete(f) => {
if f.actual() == 0 {
None
} else {
Some(f.actual() - 1)
}
}
};
let track = match track_index {
None => self.project.master_track()?,
Some(i) => get_track_by_scoped_index(self.project, i, self.mode.scope())
.ok_or("track not available")?,
};
select_track_exclusively_scoped(&track, self.mode);
if self.scroll_arrange_view {
Reaper::get()
.main_section()
.action_by_command_id(CommandId::new(40913))
.invoke_as_trigger(Some(track.project()), None)
.expect("built-in action should exist");
}
if self.scroll_mixer {
track.scroll_mixer();
}
Ok(HitResponse::processed_with_effect())
}
fn is_available(&self, _: ControlContext) -> bool {
self.project.is_available()
}
fn project(&self) -> Option<Project> {
Some(self.project)
}
fn process_change_event(
&self,
evt: CompoundChangeEvent,
_: ControlContext,
) -> (bool, Option<AbsoluteValue>) {
match evt {
CompoundChangeEvent::Reaper(ChangeEvent::TrackSelectedChanged(e))
if e.track.project() == self.project =>
{
(true, None)
}
_ => (false, None),
}
}
fn convert_discrete_value_to_unit_value(
&self,
value: u32,
_: ControlContext,
) -> Result<UnitValue, &'static str> {
let index = if value == 0 { None } else { Some(value - 1) };
let track_count = scoped_track_count(self.project, self.mode.scope());
let uv = convert_discrete_to_unit_value_with_none(index, track_count);
Ok(uv)
}
fn text_value(&self, _: ControlContext) -> Option<Cow<'static, str>> {
match self.first_selected_track()? {
ScopedTrack::InScope(t) => {
let name = get_track_name(&t, self.mode.scope());
Some(name.into())
}
ScopedTrack::OutOfScope { floor_track } => {
let name = get_track_name(&floor_track, self.mode.scope());
Some(format!("After {name}").into())
}
}
}
fn numeric_value(&self, _: ControlContext) -> Option<NumericValue> {
match self.first_selected_track()? {
ScopedTrack::InScope(t) => {
let index = scoped_track_index(&t, self.mode.scope())?;
Some(NumericValue::Discrete(index as i32 + 1))
}
ScopedTrack::OutOfScope { floor_track } => {
let index = scoped_track_index(&floor_track, self.mode.scope())?;
Some(NumericValue::Decimal(index as f64 + 1.5))
}
}
}
fn reaper_target_type(&self) -> Option<ReaperTargetType> {
Some(ReaperTargetType::BrowseTracks)
}
}
impl<'a> Target<'a> for BrowseTracksTarget {
type Context = ControlContext<'a>;
fn current_value(&self, _: Self::Context) -> Option<AbsoluteValue> {
match self.first_selected_track() {
None => Some(self.percentage_for(None)),
Some(ScopedTrack::InScope(track)) => {
let track_index = scoped_track_index(&track, self.mode.scope());
Some(self.percentage_for(track_index))
}
Some(ScopedTrack::OutOfScope { floor_track }) => {
let floor_track_index = scoped_track_index(&floor_track, self.mode.scope());
let floor_percentage = self.percentage_for(floor_track_index);
// Add half of the atomic step size to indicate that it's inbetween two values!
let step_size = self.step_size();
let inbetween_percentage =
floor_percentage.to_unit_value().get() + step_size.get() / 2.0;
UnitValue::try_new(inbetween_percentage).map(AbsoluteValue::Continuous)
}
}
}
fn control_type(&self, context: Self::Context) -> ControlType {
self.control_type_and_character(context).0
}
}
impl BrowseTracksTarget {
fn percentage_for(&self, track_index: Option<u32>) -> AbsoluteValue {
percentage_for_scoped_track_within_project(self.project, self.mode.scope(), track_index)
}
fn first_selected_track(&self) -> Option<ScopedTrack> {
first_selected_track_scoped(self.project, self.mode)
}
fn step_size(&self) -> UnitValue {
// `+ 1` because "<Master track>" is also a possible value.
let count = scoped_track_count(self.project, self.mode.scope()) + 1;
convert_count_to_step_size(count)
}
}
enum ScopedTrack {
InScope(Track),
/// Selected track is out of scope (e.g. we are interested in TCP scope but it#s only visible
/// in MCP).
OutOfScope {
/// Out-of-scope track comes after this in-scope track.
floor_track: Track,
},
}
pub const SELECTED_TRACK_TARGET: TargetTypeDef = TargetTypeDef {
section: TargetSection::Project,
name: "Browse tracks",
short_name: "Browse tracks",
supports_track_scrolling: true,
..DEFAULT_TARGET
};
pub fn percentage_for_scoped_track_within_project(
project: Project,
policy: TrackScope,
track_index: Option<u32>,
) -> AbsoluteValue {
let track_count = scoped_track_count(project, policy);
// Because we count "<Master track>" as a possible value, this is equal.
let max_value = track_count;
let actual_value = track_index.map(|i| i + 1).unwrap_or(0);
AbsoluteValue::Discrete(Fraction::new(actual_value, max_value))
}
fn scoped_track_count(project: Project, scope: TrackScope) -> u32 {
use TrackScope::*;
match scope {
AllTracks => project.track_count(),
TracksVisibleInTcp | TracksVisibleInMcp => {
let track_area = get_reaper_track_area_of_scope(scope);
project.tracks().filter(|t| t.is_shown(track_area)).count() as _
}
}
}
fn convert_unit_value_to_track_index(
project: Project,
scope: TrackScope,
value: UnitValue,
) -> Option<u32> {
convert_unit_to_discrete_value_with_none(value, scoped_track_count(project, scope))
}
fn select_track_exclusively_scoped(track: &Track, mode: BrowseTracksMode) {
use BrowseTracksMode::*;
match mode {
AllTracks | TracksVisibleInTcp | TracksVisibleInMcp => {
track.select_exclusively();
}
TracksVisibleInTcpAllowTwoSelections | TracksVisibleInMcpAllowTwoSelections => {
let track_area = get_reaper_track_area_of_scope(mode.scope());
for t in track
.project()
.tracks()
.filter(|t| t != track && t.is_shown(track_area))
{
t.unselect();
}
track.select();
}
}
}
fn first_selected_track_scoped(project: Project, mode: BrowseTracksMode) -> Option<ScopedTrack> {
use BrowseTracksMode::*;
let master_track_behavior = MasterTrackBehavior::ExcludeMasterTrack;
match mode {
AllTracks => project
.first_selected_track(master_track_behavior)
.map(ScopedTrack::InScope),
TracksVisibleInTcp | TracksVisibleInMcp => {
let first_selected_track = project.first_selected_track(master_track_behavior)?;
let track_area = get_reaper_track_area_of_scope(mode.scope());
if first_selected_track.is_shown(track_area) {
Some(ScopedTrack::InScope(first_selected_track))
} else {
let selected_track_index = first_selected_track.index()?;
// Find the first visible track above the currently selected one
project
.tracks()
// Enumerate from first track
.enumerate()
// Search starting from last track
.rev()
.find(|(i, t)| *i < selected_track_index as usize && t.is_shown(track_area))
.map(|(_, floor_track)| ScopedTrack::OutOfScope { floor_track })
}
}
TracksVisibleInTcpAllowTwoSelections | TracksVisibleInMcpAllowTwoSelections => {
let mut candidate = None;
for t in project.selected_tracks(master_track_behavior) {
let track_area = get_reaper_track_area_of_scope(mode.scope());
let other_track_area = get_other_track_area(track_area);
if t.is_shown(track_area) {
// Track is shown in the relevant area. Good.
if candidate.is_none() && t.is_shown(other_track_area) {
// Track is also shown in the other area, so it might not be the final
// result, but it's at least a candidate.
candidate = Some(t);
} else {
// Track is only shown in the relevant area. Perfect.
return Some(ScopedTrack::InScope(t));
}
}
}
candidate.map(ScopedTrack::InScope)
}
}
}
fn get_other_track_area(track_area: reaper_medium::TrackArea) -> reaper_medium::TrackArea {
use reaper_medium::TrackArea::*;
match track_area {
Tcp => Mcp,
Mcp => Tcp,
}
}
@@ -0,0 +1,193 @@
use crate::domain::ui_util::parse_unit_value_from_percentage;
use crate::domain::{
convert_count_to_step_size, CompartmentKind, CompartmentParamIndex, CompoundChangeEvent,
ControlContext, EffectiveParamValue, ExtendedProcessorContext, HitResponse,
MappingControlContext, PluginParamIndex, RealearnTarget, ReaperTarget, ReaperTargetType,
TargetCharacter, TargetSection, TargetTypeDef, UnresolvedReaperTargetDef, DEFAULT_TARGET,
};
use helgoboss_learn::{AbsoluteValue, ControlType, ControlValue, Fraction, Target, UnitValue};
use reaper_medium::ReaperNormalizedFxParamValue;
use std::num::NonZeroU32;
#[derive(Debug)]
pub struct UnresolvedCompartmentParameterValueTarget {
pub compartment: CompartmentKind,
pub index: CompartmentParamIndex,
}
impl UnresolvedReaperTargetDef for UnresolvedCompartmentParameterValueTarget {
fn resolve(
&self,
_: ExtendedProcessorContext,
_: CompartmentKind,
) -> Result<Vec<ReaperTarget>, &'static str> {
Ok(vec![ReaperTarget::CompartmentParameterValue(
CompartmentParameterValueTarget {
compartment: self.compartment,
index: self.index,
},
)])
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CompartmentParameterValueTarget {
pub compartment: CompartmentKind,
pub index: CompartmentParamIndex,
}
impl CompartmentParameterValueTarget {
fn value_count(&self, context: ControlContext) -> Option<NonZeroU32> {
context
.unit
.borrow()
.parameter_manager()
.params()
.compartment_params(self.compartment)
.at(self.index)
.setting()
.value_count
}
fn plugin_param_index(&self) -> PluginParamIndex {
self.compartment.to_plugin_param_index(self.index)
}
}
impl RealearnTarget for CompartmentParameterValueTarget {
fn control_type_and_character(
&self,
context: ControlContext,
) -> (ControlType, TargetCharacter) {
let value_count = self.value_count(context);
if let Some(c) = value_count {
(
ControlType::AbsoluteDiscrete {
atomic_step_size: convert_count_to_step_size(c.get()),
is_retriggerable: false,
},
TargetCharacter::Discrete,
)
} else {
(ControlType::AbsoluteContinuous, TargetCharacter::Continuous)
}
}
fn hit(
&mut self,
value: ControlValue,
context: MappingControlContext,
) -> Result<HitResponse, &'static str> {
let unit_value = value.to_unit_value()?;
let plugin_param_index = self.plugin_param_index();
if context.control_context.unit.borrow().is_main_unit() {
// The main unit of an instance is special in that its compartment parameters are
// connected to the VST plug-in parameters. That's why we should change the VST plug-in
// parameter directly for reasons of unidirectional data flow.
context
.control_context
.processor_context
.containing_fx()
.parameter_by_index(plugin_param_index.get())
.set_reaper_normalized_value(ReaperNormalizedFxParamValue::new(unit_value.get()))?;
} else {
// Compartment parameters of additional units are purely internal, so we need to
// control them internally.
context
.control_context
.unit
.borrow()
.parameter_manager()
.set_single_parameter(plugin_param_index, unit_value.get() as _);
}
Ok(HitResponse::processed_with_effect())
}
fn is_available(&self, _: ControlContext) -> bool {
true
}
fn process_change_event(
&self,
evt: CompoundChangeEvent,
_: ControlContext,
) -> (bool, Option<AbsoluteValue>) {
match evt {
CompoundChangeEvent::CompartmentParameter(index)
if index == self.plugin_param_index() =>
{
(true, None)
}
_ => (false, None),
}
}
fn parse_as_value(
&self,
text: &str,
context: ControlContext,
) -> Result<UnitValue, &'static str> {
match self.value_count(context) {
None => parse_unit_value_from_percentage(text),
Some(_) => self.parse_value_from_discrete_value(text, context),
}
}
fn convert_discrete_value_to_unit_value(
&self,
value: u32,
context: ControlContext,
) -> Result<UnitValue, &'static str> {
let value_count = self.value_count(context).ok_or("not supported")?;
let step_size = convert_count_to_step_size(value_count.get());
let result = (value as f64 * step_size.get()).try_into()?;
Ok(result)
}
fn convert_unit_value_to_discrete_value(
&self,
input: UnitValue,
context: ControlContext,
) -> Result<u32, &'static str> {
let value_count = self.value_count(context).ok_or("not supported")?;
let step_size = convert_count_to_step_size(value_count.get());
let val = (input.get() / step_size.get()).round() as u32;
Ok(val)
}
fn reaper_target_type(&self) -> Option<ReaperTargetType> {
Some(ReaperTargetType::CompartmentParameterValue)
}
}
impl<'a> Target<'a> for CompartmentParameterValueTarget {
type Context = ControlContext<'a>;
fn current_value(&self, context: Self::Context) -> Option<AbsoluteValue> {
let unit = context.unit.borrow();
let params = unit.parameter_manager().params();
let param = params.compartment_params(self.compartment).at(self.index);
let value = param.effective_value();
let abs_val = match value {
EffectiveParamValue::Continuous(v) => {
AbsoluteValue::Continuous(UnitValue::new_clamped(v))
}
EffectiveParamValue::Discrete(v) => {
let value_count = param.setting().value_count.unwrap();
AbsoluteValue::Discrete(Fraction::new(v, value_count.get() - 1))
}
};
Some(abs_val)
}
fn control_type(&self, context: Self::Context) -> ControlType {
self.control_type_and_character(context).0
}
}
pub const COMPARTMENT_PARAMETER_VALUE_TARGET: TargetTypeDef = TargetTypeDef {
section: TargetSection::ReaLearn,
name: "Set compartment parameter value",
short_name: "Set compartment parameter value",
..DEFAULT_TARGET
};
@@ -0,0 +1,90 @@
use crate::domain::{
CompartmentKind, ControlContext, ExtendedProcessorContext, HitResponse, MappingControlContext,
RealearnTarget, ReaperTarget, ReaperTargetType, TargetCharacter, TargetSection, TargetTypeDef,
UnresolvedReaperTargetDef, DEFAULT_TARGET,
};
use helgoboss_learn::{AbsoluteValue, ControlType, ControlValue, Target};
#[derive(Debug)]
pub struct UnresolvedDummyTarget;
impl UnresolvedReaperTargetDef for UnresolvedDummyTarget {
fn resolve(
&self,
_: ExtendedProcessorContext,
_: CompartmentKind,
) -> Result<Vec<ReaperTarget>, &'static str> {
Ok(vec![ReaperTarget::Dummy(DummyTarget::new())])
}
fn can_be_affected_by_change_events(&self) -> bool {
// We don't want to be refreshed because we maintain an artificial value.
false
}
}
#[derive(Clone, Debug, Eq, PartialEq, Default)]
pub struct DummyTarget {
// For making basic toggle/relative control possible.
artificial_value: AbsoluteValue,
}
impl DummyTarget {
pub fn new() -> Self {
Self::default()
}
fn control_type_and_character_simple(&self) -> (ControlType, TargetCharacter) {
(
ControlType::AbsoluteContinuousRetriggerable,
TargetCharacter::Continuous,
)
}
}
impl RealearnTarget for DummyTarget {
fn control_type_and_character(&self, _: ControlContext) -> (ControlType, TargetCharacter) {
self.control_type_and_character_simple()
}
fn hit(
&mut self,
value: ControlValue,
_: MappingControlContext,
) -> Result<HitResponse, &'static str> {
let value = value.to_absolute_value()?;
self.artificial_value = value;
Ok(HitResponse::processed_with_effect())
}
fn is_available(&self, _: ControlContext) -> bool {
true
}
fn supports_automatic_feedback(&self) -> bool {
false
}
fn reaper_target_type(&self) -> Option<ReaperTargetType> {
Some(ReaperTargetType::Dummy)
}
}
impl Target<'_> for DummyTarget {
type Context = ();
fn current_value(&self, _context: ()) -> Option<AbsoluteValue> {
Some(self.artificial_value)
}
fn control_type(&self, _: Self::Context) -> ControlType {
self.control_type_and_character_simple().0
}
}
pub const DUMMY_TARGET: TargetTypeDef = TargetTypeDef {
section: TargetSection::ReaLearn,
name: "Dummy target",
short_name: "Dummy",
..DEFAULT_TARGET
};
@@ -0,0 +1,137 @@
use crate::domain::{
format_value_as_on_off, CompartmentKind, CompoundChangeEvent, ControlContext,
EnableInstancesArgs, Exclusivity, ExtendedProcessorContext, HitResponse, MappingControlContext,
RealearnTarget, ReaperTarget, ReaperTargetType, TagScope, TargetCharacter, TargetSection,
TargetTypeDef, UnitEvent, UnresolvedReaperTargetDef, DEFAULT_TARGET,
};
use helgoboss_learn::{AbsoluteValue, ControlType, ControlValue, Target, UnitValue};
use helgobox_api::persistence::InstanceTagKind;
use std::borrow::Cow;
#[derive(Debug)]
pub struct UnresolvedEnableInstancesTarget {
pub scope: TagScope,
pub tag_kind: InstanceTagKind,
pub exclusivity: Exclusivity,
}
impl UnresolvedReaperTargetDef for UnresolvedEnableInstancesTarget {
fn resolve(
&self,
_: ExtendedProcessorContext,
_: CompartmentKind,
) -> Result<Vec<ReaperTarget>, &'static str> {
Ok(vec![ReaperTarget::EnableInstances(EnableInstancesTarget {
scope: self.scope.clone(),
tag_kind: self.tag_kind,
exclusivity: self.exclusivity,
})])
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct EnableInstancesTarget {
pub scope: TagScope,
pub tag_kind: InstanceTagKind,
pub exclusivity: Exclusivity,
}
impl RealearnTarget for EnableInstancesTarget {
fn control_type_and_character(&self, _: ControlContext) -> (ControlType, TargetCharacter) {
(
ControlType::AbsoluteContinuousRetriggerable,
TargetCharacter::Switch,
)
}
fn hit(
&mut self,
value: ControlValue,
context: MappingControlContext,
) -> Result<HitResponse, &'static str> {
let value = value.to_unit_value()?;
let is_enable = !value.is_zero();
let args = EnableInstancesArgs {
tag_kind: self.tag_kind,
common: context
.control_context
.create_modify_unit_container_common_args(&self.scope),
is_enable,
exclusivity: self.exclusivity,
};
let tags = context
.control_context
.unit_container
.enable_instances(args);
let mut instance_state = context.control_context.unit.borrow_mut();
use Exclusivity::*;
if self.exclusivity == Exclusive || (self.exclusivity == ExclusiveOnOnly && is_enable) {
// Completely replace
let new_active_tags = tags.unwrap_or_else(|| self.scope.tags.clone());
instance_state.set_active_instance_tags(new_active_tags);
} else {
// Add or remove
instance_state.activate_or_deactivate_instance_tags(&self.scope.tags, is_enable);
}
Ok(HitResponse::processed_with_effect())
}
fn is_available(&self, _: ControlContext) -> bool {
true
}
fn process_change_event(
&self,
evt: CompoundChangeEvent,
_: ControlContext,
) -> (bool, Option<AbsoluteValue>) {
match evt {
CompoundChangeEvent::Unit(UnitEvent::ActiveInstanceTags) => (true, None),
_ => (false, None),
}
}
fn text_value(&self, context: ControlContext) -> Option<Cow<'static, str>> {
Some(format_value_as_on_off(self.current_value(context)?.to_unit_value()).into())
}
fn reaper_target_type(&self) -> Option<ReaperTargetType> {
Some(ReaperTargetType::EnableInstances)
}
}
impl<'a> Target<'a> for EnableInstancesTarget {
type Context = ControlContext<'a>;
fn current_value(&self, context: Self::Context) -> Option<AbsoluteValue> {
let instance_state = context.unit.borrow();
use Exclusivity::*;
let active = match self.exclusivity {
NonExclusive => {
instance_state.at_least_those_instance_tags_are_active(&self.scope.tags)
}
Exclusive | ExclusiveOnOnly => {
instance_state.only_these_instance_tags_are_active(&self.scope.tags)
}
};
let uv = if active {
UnitValue::MAX
} else {
UnitValue::MIN
};
Some(AbsoluteValue::Continuous(uv))
}
fn control_type(&self, context: Self::Context) -> ControlType {
self.control_type_and_character(context).0
}
}
pub const ENABLE_INSTANCES_TARGET: TargetTypeDef = TargetTypeDef {
section: TargetSection::ReaLearn,
name: "Enable/disable instances",
short_name: "Enable/disable instances",
supports_tags: true,
supports_exclusivity: true,
..DEFAULT_TARGET
};
@@ -0,0 +1,193 @@
use crate::domain::{
format_value_as_on_off, CompartmentKind, CompoundChangeEvent, ControlContext, DomainEvent,
Exclusivity, ExtendedProcessorContext, HitInstruction, HitInstructionContext,
HitInstructionResponse, HitResponse, MappingControlContext, MappingData,
MappingEnabledChangeRequestedEvent, RealearnTarget, ReaperTarget, ReaperTargetType, TagScope,
TargetCharacter, TargetSection, TargetTypeDef, UnitEvent, UnresolvedReaperTargetDef,
DEFAULT_TARGET,
};
use helgoboss_learn::{AbsoluteValue, ControlType, ControlValue, Target, UnitValue};
use std::borrow::Cow;
use std::collections::HashSet;
#[derive(Debug)]
pub struct UnresolvedEnableMappingsTarget {
pub compartment: CompartmentKind,
pub scope: TagScope,
pub exclusivity: Exclusivity,
}
impl UnresolvedReaperTargetDef for UnresolvedEnableMappingsTarget {
fn resolve(
&self,
_: ExtendedProcessorContext,
_: CompartmentKind,
) -> Result<Vec<ReaperTarget>, &'static str> {
Ok(vec![ReaperTarget::EnableMappings(EnableMappingsTarget {
compartment: self.compartment,
scope: self.scope.clone(),
exclusivity: self.exclusivity,
})])
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct EnableMappingsTarget {
/// This must always correspond to the compartment of the containing mapping, otherwise it will
/// lead to strange behavior.
pub compartment: CompartmentKind,
pub scope: TagScope,
pub exclusivity: Exclusivity,
}
impl RealearnTarget for EnableMappingsTarget {
fn control_type_and_character(&self, _: ControlContext) -> (ControlType, TargetCharacter) {
(
ControlType::AbsoluteContinuousRetriggerable,
TargetCharacter::Switch,
)
}
fn hit(
&mut self,
value: ControlValue,
context: MappingControlContext,
) -> Result<HitResponse, &'static str> {
let value = value.to_unit_value()?;
let is_enable = !value.is_zero();
struct EnableMappingsInstruction {
compartment: CompartmentKind,
scope: TagScope,
mapping_data: MappingData,
is_enable: bool,
exclusivity: Exclusivity,
}
impl HitInstruction for EnableMappingsInstruction {
fn execute(self: Box<Self>, context: HitInstructionContext) -> HitInstructionResponse {
let mut activated_inverse_tags = HashSet::default();
for m in context.mappings.values_mut() {
// Don't touch ourselves.
if m.id() == self.mapping_data.mapping_id {
continue;
}
// Determine how to change the mappings.
let flag = match self.scope.determine_enable_disable_change(
self.exclusivity,
m.tags(),
self.is_enable,
) {
None => continue,
Some(f) => f,
};
if self.exclusivity == Exclusivity::Exclusive && !self.is_enable {
// Collect all *other* mapping tags because they are going to be activated
// and we have to know about them!
activated_inverse_tags.extend(m.tags().iter().cloned());
}
// Finally request change of mapping enabled state!
context.domain_event_handler.handle_event_ignoring_error(
DomainEvent::MappingEnabledChangeRequested(
MappingEnabledChangeRequestedEvent {
compartment: m.compartment(),
mapping_id: m.id(),
is_enabled: if self.is_enable { flag } else { !flag },
},
),
);
}
let mut instance_state = context.control_context.unit.borrow_mut();
use Exclusivity::*;
if self.exclusivity == Exclusive
|| (self.exclusivity == ExclusiveOnOnly && self.is_enable)
{
// Completely replace
let new_active_tags = if self.is_enable {
self.scope.tags.clone()
} else {
activated_inverse_tags
};
instance_state.set_active_mapping_tags(self.compartment, new_active_tags);
} else {
// Add or remove
instance_state.activate_or_deactivate_mapping_tags(
self.compartment,
&self.scope.tags,
self.is_enable,
);
}
HitInstructionResponse::CausedEffect(vec![])
}
}
let instruction = EnableMappingsInstruction {
compartment: self.compartment,
// So far this clone is okay because enabling/disable mappings is not something that
// happens every few milliseconds. No need to use a ref to this target.
scope: self.scope.clone(),
mapping_data: context.mapping_data,
is_enable,
exclusivity: self.exclusivity,
};
Ok(HitResponse::hit_instruction(Box::new(instruction)))
}
fn is_available(&self, _: ControlContext) -> bool {
true
}
fn process_change_event(
&self,
evt: CompoundChangeEvent,
_: ControlContext,
) -> (bool, Option<AbsoluteValue>) {
match evt {
CompoundChangeEvent::Unit(UnitEvent::ActiveMappingTags { compartment, .. })
if *compartment == self.compartment =>
{
(true, None)
}
_ => (false, None),
}
}
fn text_value(&self, context: ControlContext) -> Option<Cow<'static, str>> {
Some(format_value_as_on_off(self.current_value(context)?.to_unit_value()).into())
}
fn reaper_target_type(&self) -> Option<ReaperTargetType> {
Some(ReaperTargetType::EnableMappings)
}
}
impl<'a> Target<'a> for EnableMappingsTarget {
type Context = ControlContext<'a>;
fn current_value(&self, context: Self::Context) -> Option<AbsoluteValue> {
let instance_state = context.unit.borrow();
use Exclusivity::*;
let active = match self.exclusivity {
NonExclusive => instance_state
.at_least_those_mapping_tags_are_active(self.compartment, &self.scope.tags),
Exclusive | ExclusiveOnOnly => instance_state
.only_these_mapping_tags_are_active(self.compartment, &self.scope.tags),
};
let uv = if active {
UnitValue::MAX
} else {
UnitValue::MIN
};
Some(AbsoluteValue::Continuous(uv))
}
fn control_type(&self, context: Self::Context) -> ControlType {
self.control_type_and_character(context).0
}
}
pub const ENABLE_MAPPINGS_TARGET: TargetTypeDef = TargetTypeDef {
section: TargetSection::ReaLearn,
name: "Enable/disable mappings",
short_name: "Enable/disable mappings",
supports_tags: true,
supports_exclusivity: true,
..DEFAULT_TARGET
};
@@ -0,0 +1,129 @@
use crate::domain::{
format_value_as_on_off, CompartmentKind, CompoundChangeEvent, ControlContext, EnableUnitsArgs,
Exclusivity, ExtendedProcessorContext, HitResponse, MappingControlContext, RealearnTarget,
ReaperTarget, ReaperTargetType, TagScope, TargetCharacter, TargetSection, TargetTypeDef,
UnitEvent, UnresolvedReaperTargetDef, DEFAULT_TARGET,
};
use helgoboss_learn::{AbsoluteValue, ControlType, ControlValue, Target, UnitValue};
use std::borrow::Cow;
#[derive(Debug)]
pub struct UnresolvedEnableUnitsTarget {
pub scope: TagScope,
pub exclusivity: Exclusivity,
}
impl UnresolvedReaperTargetDef for UnresolvedEnableUnitsTarget {
fn resolve(
&self,
_: ExtendedProcessorContext,
_: CompartmentKind,
) -> Result<Vec<ReaperTarget>, &'static str> {
let target = EnableUnitsTarget {
scope: self.scope.clone(),
exclusivity: self.exclusivity,
};
Ok(vec![ReaperTarget::EnableUnits(target)])
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct EnableUnitsTarget {
pub scope: TagScope,
pub exclusivity: Exclusivity,
}
impl RealearnTarget for EnableUnitsTarget {
fn control_type_and_character(&self, _: ControlContext) -> (ControlType, TargetCharacter) {
(
ControlType::AbsoluteContinuousRetriggerable,
TargetCharacter::Switch,
)
}
fn hit(
&mut self,
value: ControlValue,
context: MappingControlContext,
) -> Result<HitResponse, &'static str> {
let value = value.to_unit_value()?;
let is_enable = !value.is_zero();
let args = EnableUnitsArgs {
common: context
.control_context
.create_modify_unit_container_common_args(&self.scope),
is_enable,
exclusivity: self.exclusivity,
};
let tags = context.control_context.unit_container.enable_units(args);
let mut unit = context.control_context.unit.borrow_mut();
if self.exclusivity == Exclusivity::Exclusive
|| (self.exclusivity == Exclusivity::ExclusiveOnOnly && is_enable)
{
// Completely replace
let new_active_tags = tags.unwrap_or_else(|| self.scope.tags.clone());
unit.set_active_unit_tags(new_active_tags);
} else {
// Add or remove
unit.activate_or_deactivate_unit_tags(&self.scope.tags, is_enable);
}
Ok(HitResponse::processed_with_effect())
}
fn is_available(&self, _: ControlContext) -> bool {
true
}
fn process_change_event(
&self,
evt: CompoundChangeEvent,
_: ControlContext,
) -> (bool, Option<AbsoluteValue>) {
match evt {
CompoundChangeEvent::Unit(UnitEvent::ActiveUnitTags) => (true, None),
_ => (false, None),
}
}
fn text_value(&self, context: ControlContext) -> Option<Cow<'static, str>> {
Some(format_value_as_on_off(self.current_value(context)?.to_unit_value()).into())
}
fn reaper_target_type(&self) -> Option<ReaperTargetType> {
Some(ReaperTargetType::EnableUnits)
}
}
impl<'a> Target<'a> for EnableUnitsTarget {
type Context = ControlContext<'a>;
fn current_value(&self, context: Self::Context) -> Option<AbsoluteValue> {
let unit_state = context.unit.borrow();
use Exclusivity::*;
let active = match self.exclusivity {
NonExclusive => unit_state.at_least_those_unit_tags_are_active(&self.scope.tags),
Exclusive | ExclusiveOnOnly => {
unit_state.only_these_unit_tags_are_active(&self.scope.tags)
}
};
let uv = if active {
UnitValue::MAX
} else {
UnitValue::MIN
};
Some(AbsoluteValue::Continuous(uv))
}
fn control_type(&self, context: Self::Context) -> ControlType {
self.control_type_and_character(context).0
}
}
pub const ENABLE_UNITS_TARGET: TargetTypeDef = TargetTypeDef {
section: TargetSection::ReaLearn,
name: "Enable/disable units",
short_name: "Enable/disable units",
supports_tags: true,
supports_exclusivity: true,
..DEFAULT_TARGET
};
@@ -0,0 +1,136 @@
use crate::domain::{
format_value_as_on_off, fx_enable_unit_value, CompartmentKind, CompoundChangeEvent,
ControlContext, ExtendedProcessorContext, FxDescriptor, HitResponse, MappingControlContext,
RealearnTarget, ReaperTarget, ReaperTargetType, TargetCharacter, TargetSection, TargetTypeDef,
UnresolvedReaperTargetDef, DEFAULT_TARGET,
};
use helgoboss_learn::{AbsoluteValue, ControlType, ControlValue, Target, UnitValue};
use reaper_high::{ChangeEvent, Fx, Project, Track};
use reaper_medium::ParamId;
use std::borrow::Cow;
#[derive(Debug)]
pub struct UnresolvedFxEnableTarget {
pub fx_descriptor: FxDescriptor,
}
impl UnresolvedReaperTargetDef for UnresolvedFxEnableTarget {
fn resolve(
&self,
context: ExtendedProcessorContext,
compartment: CompartmentKind,
) -> Result<Vec<ReaperTarget>, &'static str> {
Ok(self
.fx_descriptor
.resolve(context, compartment)?
.into_iter()
.map(|fx| {
ReaperTarget::FxEnable(FxEnableTarget {
bypass_param_index: fx.parameter_by_id(ParamId::Bypass).map(|p| p.index()),
fx,
})
})
.collect())
}
fn fx_descriptor(&self) -> Option<&FxDescriptor> {
Some(&self.fx_descriptor)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FxEnableTarget {
pub fx: Fx,
pub bypass_param_index: Option<u32>,
}
impl RealearnTarget for FxEnableTarget {
fn control_type_and_character(&self, _: ControlContext) -> (ControlType, TargetCharacter) {
(ControlType::AbsoluteContinuous, TargetCharacter::Switch)
}
fn format_value(&self, value: UnitValue, _: ControlContext) -> String {
format_value_as_on_off(value).to_string()
}
fn hit(
&mut self,
value: ControlValue,
_: MappingControlContext,
) -> Result<HitResponse, &'static str> {
if value.to_unit_value()?.is_zero() {
self.fx.disable()?;
} else {
self.fx.enable()?;
}
Ok(HitResponse::processed_with_effect())
}
fn is_available(&self, _: ControlContext) -> bool {
self.fx.is_available()
}
fn project(&self) -> Option<Project> {
self.fx.project()
}
fn track(&self) -> Option<&Track> {
self.fx.track()
}
fn fx(&self) -> Option<&Fx> {
Some(&self.fx)
}
fn process_change_event(
&self,
evt: CompoundChangeEvent,
_: ControlContext,
) -> (bool, Option<AbsoluteValue>) {
match evt {
CompoundChangeEvent::Reaper(ChangeEvent::FxEnabledChanged(e)) if e.fx == self.fx => (
true,
Some(AbsoluteValue::Continuous(fx_enable_unit_value(e.new_value))),
),
CompoundChangeEvent::Reaper(ChangeEvent::FxParameterValueChanged(e))
if Some(e.parameter.index()) == self.bypass_param_index
&& e.parameter.fx() == &self.fx =>
{
(true, None)
}
_ => (false, None),
}
}
fn text_value(&self, context: ControlContext) -> Option<Cow<'static, str>> {
Some(format_value_as_on_off(self.current_value(context)?.to_unit_value()).into())
}
fn reaper_target_type(&self) -> Option<ReaperTargetType> {
Some(ReaperTargetType::FxEnable)
}
}
impl<'a> Target<'a> for FxEnableTarget {
type Context = ControlContext<'a>;
fn current_value(&self, _: Self::Context) -> Option<AbsoluteValue> {
Some(AbsoluteValue::Continuous(fx_enable_unit_value(
self.fx.is_enabled(),
)))
}
fn control_type(&self, context: Self::Context) -> ControlType {
self.control_type_and_character(context).0
}
}
pub const FX_ENABLE_TARGET: TargetTypeDef = TargetTypeDef {
section: TargetSection::Fx,
name: "Enable/disable",
short_name: "Enable/disable FX",
hint: "No feedback from automation",
supports_track: true,
supports_fx: true,
..DEFAULT_TARGET
};
@@ -0,0 +1,109 @@
use crate::domain::{
format_value_as_on_off, fx_online_unit_value, CompartmentKind, ControlContext,
ExtendedProcessorContext, FeedbackResolution, FxDescriptor, HitResponse, MappingControlContext,
RealearnTarget, ReaperTarget, ReaperTargetType, TargetCharacter, TargetSection, TargetTypeDef,
UnresolvedReaperTargetDef, DEFAULT_TARGET,
};
use helgoboss_learn::{AbsoluteValue, ControlType, ControlValue, Target, UnitValue};
use reaper_high::{Fx, Project, Track};
use std::borrow::Cow;
#[derive(Debug)]
pub struct UnresolvedFxOnlineTarget {
pub fx_descriptor: FxDescriptor,
}
impl UnresolvedReaperTargetDef for UnresolvedFxOnlineTarget {
fn resolve(
&self,
context: ExtendedProcessorContext,
compartment: CompartmentKind,
) -> Result<Vec<ReaperTarget>, &'static str> {
Ok(self
.fx_descriptor
.resolve(context, compartment)?
.into_iter()
.map(|fx| ReaperTarget::FxOnline(FxOnlineTarget { fx }))
.collect())
}
fn fx_descriptor(&self) -> Option<&FxDescriptor> {
Some(&self.fx_descriptor)
}
fn feedback_resolution(&self) -> Option<FeedbackResolution> {
Some(FeedbackResolution::High)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FxOnlineTarget {
pub fx: Fx,
}
impl RealearnTarget for FxOnlineTarget {
fn control_type_and_character(&self, _: ControlContext) -> (ControlType, TargetCharacter) {
(ControlType::AbsoluteContinuous, TargetCharacter::Switch)
}
fn format_value(&self, value: UnitValue, _: ControlContext) -> String {
format_value_as_on_off(value).to_string()
}
fn hit(
&mut self,
value: ControlValue,
_: MappingControlContext,
) -> Result<HitResponse, &'static str> {
let online = !value.to_unit_value()?.is_zero();
self.fx.set_online(online)?;
Ok(HitResponse::processed_with_effect())
}
fn is_available(&self, _: ControlContext) -> bool {
self.fx.is_available()
}
fn project(&self) -> Option<Project> {
self.fx.project()
}
fn track(&self) -> Option<&Track> {
self.fx.track()
}
fn fx(&self) -> Option<&Fx> {
Some(&self.fx)
}
fn text_value(&self, context: ControlContext) -> Option<Cow<'static, str>> {
Some(format_value_as_on_off(self.current_value(context)?.to_unit_value()).into())
}
fn reaper_target_type(&self) -> Option<ReaperTargetType> {
Some(ReaperTargetType::FxOnline)
}
}
impl<'a> Target<'a> for FxOnlineTarget {
type Context = ControlContext<'a>;
fn current_value(&self, _: Self::Context) -> Option<AbsoluteValue> {
Some(AbsoluteValue::Continuous(fx_online_unit_value(
self.fx.is_online(),
)))
}
fn control_type(&self, context: Self::Context) -> ControlType {
self.control_type_and_character(context).0
}
}
pub const FX_ONLINE_TARGET: TargetTypeDef = TargetTypeDef {
section: TargetSection::Fx,
name: "Set online/offline",
short_name: "On/off-line FX",
supports_track: true,
supports_fx: true,
..DEFAULT_TARGET
};

Some files were not shown because too many files have changed in this diff Show More