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:
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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("1ä").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
Reference in New Issue
Block a user