rill_patchbay/engine.rs
1//! Control and automation subsystem.
2//!
3//! Provides event mapping (MIDI/OSC → parameters), automaton-based
4//! modulation (LFO, envelopes), and a two-thread model with lock-free
5//! queues for control → signal communication.
6
7use std::fmt::Debug;
8use std::sync::{Arc, Mutex};
9
10use rill_core::prelude::*;
11use rill_core::queues::{AutomatonCommand, CommandEnum, SetParameter, SignalOrigin};
12use rill_core_actor::{ActorRef, ActorSystem};
13
14pub use crate::automaton::{EnvelopeAutomaton, LfoAutomaton, LfoWaveform, Range};
15use crate::strategy::{ConflictStrategy, ControlStrategy};
16
17// =============================================================================
18// 1. Event patterns
19// =============================================================================
20
21/// What aspect of a MIDI note event to extract for mapping.
22#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
24pub enum MidiNoteKind {
25 /// Extracts frequency: `midi_to_freq(note)`. Note Off produces no value.
26 Frequency,
27 /// Extracts amplitude: `velocity / 127` (On) or `0.0` (Off).
28 #[default]
29 Amplitude,
30 /// Extracts gate: `1.0` (On) or `0.0` (Off).
31 Gate,
32}
33
34/// A pattern for matching controller events.
35#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
36#[derive(Debug, Clone, PartialEq, Eq, Hash)]
37pub enum EventPattern {
38 /// Matches any button event regardless of ID.
39 AnyButton,
40 /// Matches a button event with a specific hardware ID.
41 ButtonId(u32),
42 /// Matches any knob event regardless of ID.
43 AnyKnob,
44 /// Matches a knob event with a specific hardware ID.
45 KnobId(u32),
46 /// Matches any fader event regardless of ID.
47 AnyFader,
48 /// Matches a fader event with a specific hardware ID.
49 FaderId(u32),
50 /// Matches any MIDI event (control change, note, clock, or transport).
51 AnyMidi,
52 /// Matches a MIDI control change event by controller number and optional channel.
53 MidiControl {
54 /// Optional MIDI channel filter; `None` matches any channel.
55 channel: Option<u8>,
56 /// MIDI controller number (CC index).
57 controller: u8,
58 },
59 /// Matches a MIDI note-on or note-off event and extracts a mapped value.
60 MidiNote {
61 /// Optional MIDI channel filter; `None` matches any channel.
62 channel: Option<u8>,
63 /// Optional note number filter; `None` matches any note.
64 note: Option<u8>,
65 /// Which aspect of the note event to use as the mapping value.
66 #[cfg_attr(feature = "serde", serde(default))]
67 kind: MidiNoteKind,
68 },
69 /// Matches a MIDI clock tick event.
70 MidiClock,
71 /// Matches a MIDI transport event (start, stop, or continue).
72 MidiTransport {
73 /// Optional transport kind filter; `None` matches any transport event.
74 kind: Option<MidiTransportKind>,
75 },
76 /// Matches an OSC message by exact address string.
77 OscAddress(String),
78 /// Matches an OSC message whose address contains the given substring.
79 OscPattern(String),
80}
81
82impl EventPattern {
83 /// Checks whether this pattern matches a given control event.
84 pub fn matches(&self, event: &ControlEvent) -> bool {
85 match (self, event) {
86 (EventPattern::AnyButton, ControlEvent::Button { .. }) => true,
87 (EventPattern::ButtonId(id), ControlEvent::Button { id: eid, .. }) => *id == *eid,
88 (EventPattern::AnyKnob, ControlEvent::Knob { .. }) => true,
89 (EventPattern::KnobId(id), ControlEvent::Knob { id: eid, .. }) => *id == *eid,
90 (EventPattern::AnyFader, ControlEvent::Fader { .. }) => true,
91 (EventPattern::FaderId(id), ControlEvent::Fader { id: eid, .. }) => *id == *eid,
92 (
93 EventPattern::MidiControl {
94 channel,
95 controller,
96 },
97 ControlEvent::MidiControl {
98 channel: ech,
99 controller: ectr,
100 ..
101 },
102 ) => (channel.is_none() || channel.unwrap() == *ech) && *controller == *ectr,
103 (
104 EventPattern::MidiNote { channel, note, .. },
105 ControlEvent::MidiNote {
106 channel: ech,
107 note: en,
108 ..
109 },
110 ) => {
111 (channel.is_none() || channel.unwrap() == *ech)
112 && (note.is_none() || note.unwrap() == *en)
113 }
114 (EventPattern::AnyMidi, ControlEvent::MidiControl { .. })
115 | (EventPattern::AnyMidi, ControlEvent::MidiNote { .. })
116 | (EventPattern::AnyMidi, ControlEvent::MidiClock)
117 | (EventPattern::AnyMidi, ControlEvent::MidiTransport { .. }) => true,
118 (EventPattern::MidiClock, ControlEvent::MidiClock) => true,
119 (
120 EventPattern::MidiTransport { kind },
121 ControlEvent::MidiTransport { kind: ek, .. },
122 ) => kind.is_none_or(|k| k == *ek),
123 (EventPattern::OscAddress(addr), ControlEvent::Osc { address, .. }) => addr == address,
124 (EventPattern::OscPattern(pat), ControlEvent::Osc { address, .. }) => {
125 address.contains(pat)
126 }
127 _ => false,
128 }
129 }
130}
131
132// =============================================================================
133// 2. Event types
134// =============================================================================
135
136#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
137#[derive(Debug, Clone, PartialEq)]
138/// Hardware control event from a physical interface (knob, button, fader, etc.).
139pub enum ControlEvent {
140 /// A physical button press or release.
141 Button {
142 /// Hardware control identifier.
143 id: u32,
144 /// `true` if the button is currently held down.
145 pressed: bool,
146 },
147 /// A physical knob (rotary encoder or potentiometer) event.
148 Knob {
149 /// Hardware control identifier.
150 id: u32,
151 /// Raw value in hardware-native units.
152 value: f32,
153 /// Value mapped to the [0.0, 1.0] range.
154 normalized: f32,
155 },
156 /// A physical fader (linear slider) event.
157 Fader {
158 /// Hardware control identifier.
159 id: u32,
160 /// Raw value in hardware-native units.
161 value: f32,
162 /// Value mapped to the [0.0, 1.0] range.
163 normalized: f32,
164 },
165 /// A MIDI control change message.
166 MidiControl {
167 /// MIDI channel (0-indexed).
168 channel: u8,
169 /// MIDI controller number.
170 controller: u8,
171 /// Raw 7-bit MIDI value.
172 value: u8,
173 /// Value normalized to [0.0, 1.0].
174 normalized: f32,
175 },
176 /// A MIDI note-on or note-off message.
177 MidiNote {
178 /// MIDI channel (0-indexed).
179 channel: u8,
180 /// MIDI note number.
181 note: u8,
182 /// MIDI velocity value (0-127).
183 velocity: u8,
184 /// `true` for note-on, `false` for note-off.
185 on: bool,
186 },
187 /// An OSC message event.
188 Osc {
189 /// OSC address path.
190 address: String,
191 /// OSC argument list as float values.
192 args: Vec<f32>,
193 },
194 /// A MIDI clock tick event.
195 MidiClock,
196 /// A MIDI transport state change.
197 MidiTransport {
198 /// The type of transport event (start, stop, or continue).
199 kind: MidiTransportKind,
200 },
201}
202
203/// MIDI transport state.
204#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
206pub enum MidiTransportKind {
207 /// Transport started.
208 Start,
209 /// Transport stopped.
210 Stop,
211 /// Transport resumed from current position.
212 Continue,
213}
214
215impl ControlEvent {
216 /// Returns the normalized value (0.0–1.0) of this event, if it carries one.
217 pub fn normalized_value(&self) -> Option<f32> {
218 match self {
219 ControlEvent::Knob { normalized, .. } => Some(*normalized),
220 ControlEvent::Fader { normalized, .. } => Some(*normalized),
221 ControlEvent::MidiControl { normalized, .. } => Some(*normalized),
222 ControlEvent::Button { pressed, .. } => Some(if *pressed { 1.0 } else { 0.0 }),
223 _ => None,
224 }
225 }
226 /// Returns the hardware control ID attached to this event, if any.
227 pub fn id(&self) -> Option<u32> {
228 match self {
229 ControlEvent::Button { id, .. } => Some(*id),
230 ControlEvent::Knob { id, .. } => Some(*id),
231 ControlEvent::Fader { id, .. } => Some(*id),
232 _ => None,
233 }
234 }
235}
236
237// =============================================================================
238// 2b. OSC Surface
239// =============================================================================
240
241/// A single entry in an OSC control surface, binding an OSC path to an event pattern.
242#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
243#[derive(Debug, Clone)]
244pub struct OscSurfaceEntry {
245 /// The OSC address path this entry listens to.
246 pub osc_path: String,
247 /// The event pattern that triggered actions should match.
248 pub event_pattern: EventPattern,
249 #[cfg_attr(
250 feature = "serde",
251 serde(default, skip_serializing_if = "Option::is_none")
252 )]
253 /// Optional human-readable label for UI display.
254 pub label: Option<String>,
255}
256
257/// A list of OSC address → event mappings forming a control surface layout.
258pub type OscSurface = Vec<OscSurfaceEntry>;
259
260// =============================================================================
261// 3. Value transforms
262// =============================================================================
263
264/// Transfer function applied to a normalized [0,1] value before scaling to parameter range.
265#[derive(Clone)]
266pub enum Transform {
267 /// Identity: value passes through unchanged.
268 Linear,
269 /// Square mapping: finer control near zero, coarser near one.
270 Exponential,
271 /// Logarithmic mapping: finer control near maximum.
272 Logarithmic,
273 /// Reversed mapping: 1.0 becomes min, 0.0 becomes max.
274 Inverted,
275 /// User-defined custom transfer function.
276 Custom(Arc<dyn Fn(f32) -> f32 + Send + Sync>),
277}
278
279impl Debug for Transform {
280 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
281 match self {
282 Transform::Linear => write!(f, "Linear"),
283 Transform::Exponential => write!(f, "Exponential"),
284 Transform::Logarithmic => write!(f, "Logarithmic"),
285 Transform::Inverted => write!(f, "Inverted"),
286 Transform::Custom(_) => write!(f, "Custom"),
287 }
288 }
289}
290
291impl Transform {
292 /// Applies the transform to a normalized value, mapping it into the [min, max] range.
293 pub fn apply(&self, value: f32, min: f32, max: f32) -> f32 {
294 let range = max - min;
295 let normalized = value.clamp(0.0, 1.0);
296 let mapped = match self {
297 Transform::Linear => min + normalized * range,
298 Transform::Exponential => min + normalized * normalized * range,
299 Transform::Logarithmic => min + (1.0 + normalized * 9.0).log10() * range,
300 Transform::Inverted => max - normalized * range,
301 Transform::Custom(f) => min + f(normalized) * range,
302 };
303 mapped.clamp(min, max)
304 }
305}
306
307// =============================================================================
308// 4. Event mapping
309// =============================================================================
310
311/// The destination of an event mapping: a specific parameter on a specific graph node.
312#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
313#[derive(Debug, Clone)]
314pub struct Target {
315 /// Graph node that owns the target parameter.
316 pub node_id: NodeId,
317 /// Name of the parameter to control.
318 pub param_name: String,
319 /// Lower bound of the parameter value range.
320 pub min: f32,
321 /// Upper bound of the parameter value range.
322 pub max: f32,
323}
324
325/// A complete mapping from an input event to a target parameter, with a value transform.
326#[derive(Debug, Clone)]
327pub struct Mapping {
328 /// Event pattern that triggers this mapping.
329 pub pattern: EventPattern,
330 /// Target parameter to set when the pattern matches.
331 pub target: Target,
332 /// Transform applied to the normalized event value before scaling.
333 pub transform: Transform,
334 /// Human-readable name for debugging and UI.
335 pub name: String,
336 /// Whether this mapping is currently active.
337 pub enabled: bool,
338}
339
340impl Mapping {
341 /// Creates a new mapping with an auto-generated name.
342 pub fn new(pattern: EventPattern, target: Target, transform: Transform) -> Self {
343 let name = format!("{:?} -> {}", pattern, target.param_name);
344 Self {
345 pattern,
346 target,
347 transform,
348 name,
349 enabled: true,
350 }
351 }
352
353 /// Returns `true` if this mapping is enabled and matches the given event.
354 pub fn matches(&self, event: &ControlEvent) -> bool {
355 self.enabled && self.pattern.matches(event)
356 }
357
358 /// Produces a parameter-set command if the event matches this mapping.
359 pub fn apply(&self, event: &ControlEvent) -> Option<SetParameter> {
360 if !self.matches(event) {
361 return None;
362 }
363
364 // MidiNote with kind: extract value from note event, bypassing
365 // the standard normalized_value() pipeline.
366 if let (
367 EventPattern::MidiNote { kind, .. },
368 ControlEvent::MidiNote {
369 note, velocity, on, ..
370 },
371 ) = (&self.pattern, event)
372 {
373 let value = match kind {
374 MidiNoteKind::Frequency => {
375 if !*on {
376 return None;
377 }
378 // midi_to_freq produces absolute Hz — bypass Transform
379 rill_core_dsp::math::midi_to_freq::<f32>(*note)
380 }
381 MidiNoteKind::Amplitude => {
382 let raw = if *on { *velocity as f32 / 127.0 } else { 0.0 };
383 self.transform.apply(raw, self.target.min, self.target.max)
384 }
385 MidiNoteKind::Gate => {
386 let raw = if *on { 1.0 } else { 0.0 };
387 self.transform.apply(raw, self.target.min, self.target.max)
388 }
389 };
390 let pid = ParameterId::new(&self.target.param_name).unwrap();
391 return Some(SetParameter::new(
392 PortId::param(self.target.node_id, 0),
393 pid,
394 ParamValue::Float(value),
395 SignalOrigin::External(self.name.clone()),
396 ));
397 }
398
399 // All other patterns: use the standard normalized_value() pipeline.
400 let norm = event.normalized_value()?;
401 let value = self.transform.apply(norm, self.target.min, self.target.max);
402 let pid = ParameterId::new(&self.target.param_name).unwrap();
403 Some(SetParameter::new(
404 PortId::param(self.target.node_id, 0),
405 pid,
406 ParamValue::Float(value),
407 SignalOrigin::External(self.name.clone()),
408 ))
409 }
410}
411
412// =============================================================================
413// 5. Automaton core trait
414// =============================================================================
415
416/// Time in seconds, used for automaton clocks and timekeeping.
417pub type Time = f64;
418
419/// A unit action for automatons that need no external action per step.
420#[derive(Debug, Clone, Default)]
421pub struct NoAction;
422
423/// Core trait for automatons — stateful signal generators that advance per step.
424pub trait Automaton: Send + Sync + Debug {
425 /// The automaton's internal state, carried across step invocations.
426 type Internal: Clone + Send + Sync + 'static;
427 /// An optional action type driving state transitions on each step.
428 type Action: Debug + Clone + Send + Sync + Default + 'static;
429
430 /// Advances the automaton by one step, producing a new output value.
431 ///
432 /// `internal` holds mutable state, `current` is the last output value,
433 /// `time` is the elapsed time in seconds, and `action` is an optional trigger.
434 fn step(
435 &self,
436 internal: &mut Self::Internal,
437 current: &ParamValue,
438 time: Time,
439 action: &Self::Action,
440 ) -> ParamValue;
441
442 /// Returns the automaton's initial internal state (at time zero).
443 fn initial_internal(&self) -> Self::Internal;
444
445 /// Resets the automaton to its initial internal state.
446 fn reset(&self) -> Self::Internal {
447 self.initial_internal()
448 }
449
450 /// Returns the human-readable name of this automaton.
451 fn name(&self) -> &str;
452}
453
454// =============================================================================
455// 6. Parameter mapping
456// =============================================================================
457
458/// Transfer function for mapping raw automaton output [0,1] to parameter space.
459#[derive(Clone)]
460pub enum ParameterMapping {
461 /// Identity: output equals input.
462 Linear,
463 /// Square mapping: finer control near zero.
464 Exponential,
465 /// Logarithmic mapping: finer control near maximum.
466 Logarithmic,
467 /// Inverted: 1.0 maps to 0.0 and vice versa.
468 Inverted,
469 /// User-defined custom mapping function.
470 Custom(Arc<dyn Fn(f64) -> f64 + Send + Sync>),
471}
472
473impl std::fmt::Debug for ParameterMapping {
474 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
475 match self {
476 ParameterMapping::Linear => write!(f, "Linear"),
477 ParameterMapping::Exponential => write!(f, "Exponential"),
478 ParameterMapping::Logarithmic => write!(f, "Logarithmic"),
479 ParameterMapping::Inverted => write!(f, "Inverted"),
480 ParameterMapping::Custom(_) => write!(f, "Custom(<fn>)"),
481 }
482 }
483}
484
485impl ParameterMapping {
486 /// Applies this mapping to a raw value in the [0, 1] range.
487 pub fn apply(&self, raw: f64) -> f64 {
488 match self {
489 ParameterMapping::Linear => raw,
490 ParameterMapping::Exponential => raw * raw,
491 ParameterMapping::Logarithmic => (1.0 + raw * 9.0).log10(),
492 ParameterMapping::Inverted => 1.0 - raw,
493 ParameterMapping::Custom(f) => f(raw),
494 }
495 }
496}
497
498// =============================================================================
499// 7. ServoState
500// =============================================================================
501
502/// Internal runtime state of a Servo, shared between the control actor and automation logic.
503pub(crate) struct ServoState<A: Automaton> {
504 /// Current automaton internal state.
505 pub(crate) internal: A::Internal,
506 /// Most recent output value produced by the automaton.
507 pub(crate) value: ParamValue,
508 /// Elapsed time in seconds since the automaton started.
509 pub(crate) time: Time,
510 /// Whether the servo is actively stepping the automaton.
511 pub(crate) enabled: bool,
512 /// Base value for modulation strategies (offset added to modulation output).
513 pub(crate) base: f64,
514 /// When `true`, the servo is frozen from UI touch (used with TouchOverride).
515 pub(crate) frozen: bool,
516 /// Last value sent to the graph, used for change detection.
517 pub(crate) last_sent_value: f64,
518 /// Last table index sent (only used with value tables).
519 pub(crate) last_sent_index: i64,
520}
521
522// =============================================================================
523// 8. Servo — automaton-to-parameter bridge
524// =============================================================================
525
526/// Bridges an automaton to a graph parameter, stepping on every clock tick and
527/// sending control commands to the signal graph.
528pub struct Servo<A: Automaton> {
529 id: String,
530 automaton: Arc<A>,
531 state: Arc<Mutex<ServoState<A>>>,
532 graph_ref: ActorRef<CommandEnum>,
533 target_node: NodeId,
534 target_param: String,
535 mapping: ParameterMapping,
536 min: f64,
537 max: f64,
538 control: ControlStrategy,
539 conflict: ConflictStrategy,
540 table: Option<Vec<ParamValue>>,
541}
542
543impl<A: Automaton + 'static> Servo<A> {
544 /// Creates a new Servo linking an automaton to a target parameter.
545 pub fn new(
546 id: impl Into<String>,
547 automaton: A,
548 target_node: NodeId,
549 target_param: impl Into<String>,
550 mapping: ParameterMapping,
551 min: f64,
552 max: f64,
553 system: Arc<ActorSystem>,
554 graph_ref: ActorRef<CommandEnum>,
555 ) -> Self {
556 let _ = system;
557 let automaton = Arc::new(automaton);
558 let mut internal = automaton.initial_internal();
559 let initial_value = automaton.step(
560 &mut internal,
561 &ParamValue::Float(0.0),
562 0.0,
563 &A::Action::default(),
564 );
565
566 Self {
567 id: id.into(),
568 automaton,
569 state: Arc::new(Mutex::new(ServoState {
570 internal,
571 value: initial_value,
572 time: 0.0,
573 enabled: true,
574 base: (min + max) / 2.0,
575 frozen: false,
576 last_sent_value: f64::NAN,
577 last_sent_index: -1,
578 })),
579 graph_ref,
580 target_node,
581 target_param: target_param.into(),
582 mapping,
583 min,
584 max,
585 control: ControlStrategy::Absolute,
586 conflict: ConflictStrategy::LastWriteWins,
587 table: None,
588 }
589 }
590
591 /// Spawns this servo as a detached tokio actor, returning its address.
592 ///
593 /// The actor listens for `ClockTick` to step the automaton, and for
594 /// `AutomatonCommand` variants to handle enable/reset/UI value events.
595 pub fn spawn(self, system: &ActorSystem) -> ActorRef<CommandEnum> {
596 let Servo {
597 id,
598 automaton,
599 state,
600 graph_ref,
601 target_node,
602 target_param,
603 mapping,
604 min,
605 max,
606 control,
607 conflict,
608 table,
609 } = self;
610
611 let a = automaton;
612 let s = state;
613 let gr = graph_ref;
614 let nid = target_node;
615 let param = target_param;
616 let map = mapping;
617 let ctrl = control;
618 let confl = conflict;
619 let tbl = table;
620 let serv_id = id.clone();
621
622 let s2 = s.clone();
623 system.spawn_detached_tokio(
624 &format!("servo_{id}"),
625 move || {
626 Box::new(move |msg: CommandEnum| match msg {
627 CommandEnum::ClockTick(clock) => {
628 let mut state = s2.lock().unwrap();
629 if !state.enabled {
630 return;
631 }
632 let dt = clock.samples_since_last as f64 / clock.sample_rate as f64;
633 state.time += dt;
634 if state.frozen && matches!(confl, ConflictStrategy::TouchOverride) {
635 return;
636 }
637 let current_value = state.value.clone();
638 let current_time = state.time;
639 let action = A::Action::default();
640 let new_val =
641 a.step(&mut state.internal, ¤t_value, current_time, &action);
642 let raw = new_val.as_f32().unwrap_or(0.0) as f64;
643 state.value = new_val;
644
645 if let Some(ref table) = tbl {
646 let index = raw as usize;
647 if index >= table.len() {
648 return;
649 }
650 let idx = index as i64;
651 if idx == state.last_sent_index {
652 return;
653 }
654 state.last_sent_index = idx;
655 let pid = ParameterId::new(¶m).unwrap();
656 gr.send(CommandEnum::SetParameter(SetParameter::new(
657 PortId::param(nid, 0),
658 pid,
659 table[index].clone(),
660 SignalOrigin::Automaton(serv_id.clone()),
661 )));
662 return;
663 }
664
665 let mapped = map.apply(raw);
666 let base = state.base;
667 let value = match ctrl {
668 ControlStrategy::Absolute => min + mapped * (max - min),
669 ControlStrategy::Modulation { depth } => {
670 (base + mapped * depth * (max - min)).clamp(min, max)
671 }
672 };
673 if (value - state.last_sent_value).abs() < 1e-6 {
674 return;
675 }
676 state.last_sent_value = value;
677
678 let pid = ParameterId::new(¶m).unwrap();
679 gr.send(CommandEnum::SetParameter(SetParameter::new(
680 PortId::param(nid, 0),
681 pid,
682 ParamValue::Float(value as f32),
683 SignalOrigin::Automaton(serv_id.clone()),
684 )));
685 }
686 CommandEnum::Automaton(AutomatonCommand::SetEnabled { enabled, .. }) => {
687 s.lock().unwrap().enabled = enabled;
688 }
689 CommandEnum::Automaton(AutomatonCommand::Reset { .. }) => {
690 s.lock().unwrap().internal = a.reset();
691 }
692 CommandEnum::Automaton(AutomatonCommand::UiValue { value, .. }) => {
693 let mut state = s.lock().unwrap();
694 let pid = ParameterId::new(¶m).unwrap();
695 let cmd = SetParameter::new(
696 PortId::param(nid, 0),
697 pid,
698 ParamValue::Float(value as f32),
699 SignalOrigin::Automaton(serv_id.clone()),
700 );
701 match confl {
702 ConflictStrategy::TouchOverride => {
703 state.base = value;
704 state.frozen = true;
705 gr.send(CommandEnum::SetParameter(cmd));
706 }
707 ConflictStrategy::BasePlusModulation => {
708 state.base = value;
709 }
710 ConflictStrategy::LastWriteWins => {
711 gr.send(CommandEnum::SetParameter(cmd));
712 }
713 }
714 }
715 CommandEnum::Automaton(AutomatonCommand::UiRelease { .. }) => {
716 let mut state = s.lock().unwrap();
717 if state.frozen {
718 state.frozen = false;
719 }
720 }
721 _ => {}
722 })
723 },
724 1,
725 )
726 }
727
728 /// Attaches a preset value table; raw automaton output selects table entries by index.
729 pub fn with_table(mut self, table: Vec<ParamValue>) -> Self {
730 self.table = Some(table);
731 self
732 }
733
734 /// Returns this servo's unique identifier.
735 pub fn id(&self) -> &str {
736 &self.id
737 }
738}
739
740// =============================================================================
741// 9. Module trait — unified interface for sensors
742// =============================================================================
743
744/// Type-erased, heap-allocated reference to any module.
745pub type BoxedModule = Box<dyn Module>;
746
747/// Unified interface for sensor and control modules (MIDI hubs, OSC servers, etc.).
748pub trait Module: Send {
749 /// Returns this module's unique identifier.
750 fn id(&self) -> &str;
751 /// Returns the actor handle if this module has a control actor, `None` otherwise.
752 fn handle(&self) -> Option<ActorRef<CommandEnum>> {
753 None
754 }
755 /// Enables or disables the module.
756 fn set_enabled(&mut self, _enabled: bool) {}
757 /// Stops the module, joining any background threads.
758 fn stop(&mut self);
759}
760
761// =============================================================================
762// 10. Helper constructors
763// =============================================================================
764
765/// Convenience constructor for a MIDI control change mapping.
766pub fn midi_cc(
767 controller: u8,
768 channel: Option<u8>,
769 target_node: NodeId,
770 target_param: &str,
771 min: f32,
772 max: f32,
773 transform: Transform,
774) -> Mapping {
775 Mapping::new(
776 EventPattern::MidiControl {
777 channel,
778 controller,
779 },
780 Target {
781 node_id: target_node,
782 param_name: target_param.to_string(),
783 min,
784 max,
785 },
786 transform,
787 )
788}
789
790/// Convenience constructor for a MIDI note mapping.
791///
792/// Use [`MidiNoteKind`] to select which aspect of the note event to extract:
793/// - `Frequency` — `midi_to_freq(note)`, Note Off produces no value
794/// - `Amplitude` — `velocity / 127` (On) or `0.0` (Off)
795/// - `Gate` — `1.0` (On) or `0.0` (Off)
796pub fn midi_note(
797 kind: MidiNoteKind,
798 note: Option<u8>,
799 channel: Option<u8>,
800 target_node: NodeId,
801 target_param: &str,
802 min: f32,
803 max: f32,
804 transform: Transform,
805) -> Mapping {
806 Mapping::new(
807 EventPattern::MidiNote {
808 channel,
809 note,
810 kind,
811 },
812 Target {
813 node_id: target_node,
814 param_name: target_param.to_string(),
815 min,
816 max,
817 },
818 transform,
819 )
820}
821
822/// Convenience constructor for an OSC address mapping.
823pub fn osc_address(
824 address: &str,
825 target_node: NodeId,
826 target_param: &str,
827 min: f32,
828 max: f32,
829 transform: Transform,
830) -> Mapping {
831 Mapping::new(
832 EventPattern::OscAddress(address.to_string()),
833 Target {
834 node_id: target_node,
835 param_name: target_param.to_string(),
836 min,
837 max,
838 },
839 transform,
840 )
841}
842
843// =============================================================================
844// 11. Tests
845// =============================================================================
846
847#[cfg(test)]
848mod tests {
849 use super::*;
850
851 #[test]
852 fn test_midi_mapping() {
853 let node = NodeId(1);
854 let mapping = midi_cc(7, Some(1), node, "volume", 0.0, 1.0, Transform::Linear);
855 let event = ControlEvent::MidiControl {
856 channel: 1,
857 controller: 7,
858 value: 64,
859 normalized: 0.5,
860 };
861 assert!(mapping.matches(&event));
862 let cmd = mapping.apply(&event).unwrap();
863 assert_eq!(cmd.port.node_id(), node);
864 assert_eq!(cmd.parameter.as_ref(), "volume");
865 assert!((cmd.value.as_f32().unwrap() - 0.5).abs() < 1e-6);
866 }
867}