Skip to main content

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, &current_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(&param).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(&param).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(&param).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}