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// Re-export control event types from rill-core (canonical home)
18pub use rill_core::queues::control_event::{
19    ControlEvent, EventPattern, MidiNoteKind, MidiTransportKind,
20};
21
22// =============================================================================
23// 2b. OSC Surface
24// =============================================================================
25
26/// A single entry in an OSC control surface, binding an OSC path to an event pattern.
27#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
28#[derive(Debug, Clone)]
29pub struct OscSurfaceEntry {
30    /// The OSC address path this entry listens to.
31    pub osc_path: String,
32    /// The event pattern that triggered actions should match.
33    pub event_pattern: EventPattern,
34    #[cfg_attr(
35        feature = "serde",
36        serde(default, skip_serializing_if = "Option::is_none")
37    )]
38    /// Optional human-readable label for UI display.
39    pub label: Option<String>,
40}
41
42/// A list of OSC address → event mappings forming a control surface layout.
43pub type OscSurface = Vec<OscSurfaceEntry>;
44
45// =============================================================================
46// 3. Value transforms
47// =============================================================================
48
49/// Transfer function applied to a normalized `[0,1]` value before scaling to parameter range.
50#[derive(Clone)]
51pub enum Transform {
52    /// Identity: value passes through unchanged.
53    Linear,
54    /// Square mapping: finer control near zero, coarser near one.
55    Exponential,
56    /// Logarithmic mapping: finer control near maximum.
57    Logarithmic,
58    /// Reversed mapping: 1.0 becomes min, 0.0 becomes max.
59    Inverted,
60    /// User-defined custom transfer function.
61    Custom(Arc<dyn Fn(f32) -> f32 + Send + Sync>),
62}
63
64impl Debug for Transform {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        match self {
67            Transform::Linear => write!(f, "Linear"),
68            Transform::Exponential => write!(f, "Exponential"),
69            Transform::Logarithmic => write!(f, "Logarithmic"),
70            Transform::Inverted => write!(f, "Inverted"),
71            Transform::Custom(_) => write!(f, "Custom"),
72        }
73    }
74}
75
76impl Transform {
77    /// Applies the transform to a normalized value, mapping it into the [min, max] range.
78    pub fn apply(&self, value: f32, min: f32, max: f32) -> f32 {
79        let range = max - min;
80        let normalized = value.clamp(0.0, 1.0);
81        let mapped = match self {
82            Transform::Linear => min + normalized * range,
83            Transform::Exponential => min + normalized * normalized * range,
84            Transform::Logarithmic => min + (1.0 + normalized * 9.0).log10() * range,
85            Transform::Inverted => max - normalized * range,
86            Transform::Custom(f) => min + f(normalized) * range,
87        };
88        mapped.clamp(min, max)
89    }
90}
91
92// =============================================================================
93// 4. Event mapping
94// =============================================================================
95
96/// The destination of an event mapping: a specific parameter on a specific graph node.
97#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
98#[derive(Debug, Clone)]
99pub struct Target {
100    /// Graph node that owns the target parameter.
101    pub node_id: u32,
102    /// Name of the parameter to control.
103    pub param_name: String,
104    /// Lower bound of the parameter value range.
105    pub min: f32,
106    /// Upper bound of the parameter value range.
107    pub max: f32,
108}
109
110/// A complete mapping from an input event to a target parameter, with a value transform.
111#[derive(Debug, Clone)]
112pub struct Mapping {
113    /// Event pattern that triggers this mapping.
114    pub pattern: EventPattern,
115    /// Target parameter to set when the pattern matches.
116    pub target: Target,
117    /// Transform applied to the normalized event value before scaling.
118    pub transform: Transform,
119    /// Human-readable name for debugging and UI.
120    pub name: String,
121    /// Whether this mapping is currently active.
122    pub enabled: bool,
123}
124
125impl Mapping {
126    /// Creates a new mapping with an auto-generated name.
127    pub fn new(pattern: EventPattern, target: Target, transform: Transform) -> Self {
128        let name = format!("{:?} -> {}", pattern, target.param_name);
129        Self {
130            pattern,
131            target,
132            transform,
133            name,
134            enabled: true,
135        }
136    }
137
138    /// Returns `true` if this mapping is enabled and matches the given event.
139    pub fn matches(&self, event: &ControlEvent) -> bool {
140        self.enabled && self.pattern.matches(event)
141    }
142
143    /// Produces a parameter-set command if the event matches this mapping.
144    pub fn apply(&self, event: &ControlEvent) -> Option<SetParameter> {
145        if !self.matches(event) {
146            return None;
147        }
148
149        // MidiNote with kind: extract value from note event, bypassing
150        // the standard normalized_value() pipeline.
151        if let (
152            EventPattern::MidiNote { kind, .. },
153            ControlEvent::MidiNote {
154                note, velocity, on, ..
155            },
156        ) = (&self.pattern, event)
157        {
158            let value = match kind {
159                MidiNoteKind::Frequency => {
160                    if !*on {
161                        return None;
162                    }
163                    // midi_to_freq produces absolute Hz — bypass Transform
164                    rill_core_dsp::math::midi_to_freq::<f32>(*note)
165                }
166                MidiNoteKind::Amplitude => {
167                    let raw = if *on { *velocity as f32 / 127.0 } else { 0.0 };
168                    self.transform.apply(raw, self.target.min, self.target.max)
169                }
170                MidiNoteKind::Gate => {
171                    let raw = if *on { 1.0 } else { 0.0 };
172                    self.transform.apply(raw, self.target.min, self.target.max)
173                }
174            };
175            let pid = ParameterId::new(&self.target.param_name).unwrap();
176            return Some(SetParameter::new(
177                "".to_string(),
178                pid,
179                ParamValue::Float(value),
180                SignalOrigin::External(self.name.clone()),
181            ));
182        }
183
184        // All other patterns: use the standard normalized_value() pipeline.
185        let norm = event.normalized_value()?;
186        let value = self.transform.apply(norm, self.target.min, self.target.max);
187        let pid = ParameterId::new(&self.target.param_name).unwrap();
188        Some(SetParameter::new(
189            "".to_string(),
190            pid,
191            ParamValue::Float(value),
192            SignalOrigin::External(self.name.clone()),
193        ))
194    }
195}
196
197// =============================================================================
198// 5. Automaton core trait
199// =============================================================================
200
201/// Time in seconds, used for automaton clocks and timekeeping.
202pub type Time = f64;
203
204/// A unit action for automatons that need no external action per step.
205///
206/// Also implements [`Automaton`] as a no-op — useful for mapping-only servos
207/// where the automaton output is irrelevant.
208#[derive(Debug, Clone, Default)]
209pub struct NoAction;
210
211impl Automaton for NoAction {
212    type Internal = ();
213    type Action = ();
214
215    fn step(
216        &self,
217        _internal: &mut Self::Internal,
218        _current: &ParamValue,
219        _time: Time,
220        _action: &Self::Action,
221    ) -> ParamValue {
222        ParamValue::Float(0.0)
223    }
224
225    fn initial_internal(&self) -> Self::Internal {}
226
227    fn name(&self) -> &str {
228        "NoAction"
229    }
230}
231
232/// Core trait for automatons — stateful signal generators that advance per step.
233pub trait Automaton: Send + Sync + Debug {
234    /// The automaton's internal state, carried across step invocations.
235    type Internal: Clone + Send + Sync + 'static;
236    /// An optional action type driving state transitions on each step.
237    type Action: Debug + Clone + Send + Sync + Default + 'static;
238
239    /// Advances the automaton by one step, producing a new output value.
240    ///
241    /// `internal` holds mutable state, `current` is the last output value,
242    /// `time` is the elapsed time in seconds, and `action` is an optional trigger.
243    fn step(
244        &self,
245        internal: &mut Self::Internal,
246        current: &ParamValue,
247        time: Time,
248        action: &Self::Action,
249    ) -> ParamValue;
250
251    /// Returns the automaton's initial internal state (at time zero).
252    fn initial_internal(&self) -> Self::Internal;
253
254    /// Resets the automaton to its initial internal state.
255    fn reset(&self) -> Self::Internal {
256        self.initial_internal()
257    }
258
259    /// Returns the human-readable name of this automaton.
260    fn name(&self) -> &str;
261}
262
263// =============================================================================
264// 6. Parameter mapping
265// =============================================================================
266
267/// Transfer function for mapping raw automaton output `[0,1]` to parameter space.
268#[derive(Clone)]
269pub enum ParameterMapping {
270    /// Identity: output equals input.
271    Linear,
272    /// Square mapping: finer control near zero.
273    Exponential,
274    /// Logarithmic mapping: finer control near maximum.
275    Logarithmic,
276    /// Inverted: 1.0 maps to 0.0 and vice versa.
277    Inverted,
278    /// User-defined custom mapping function.
279    Custom(Arc<dyn Fn(f64) -> f64 + Send + Sync>),
280}
281
282impl std::fmt::Debug for ParameterMapping {
283    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
284        match self {
285            ParameterMapping::Linear => write!(f, "Linear"),
286            ParameterMapping::Exponential => write!(f, "Exponential"),
287            ParameterMapping::Logarithmic => write!(f, "Logarithmic"),
288            ParameterMapping::Inverted => write!(f, "Inverted"),
289            ParameterMapping::Custom(_) => write!(f, "Custom(<fn>)"),
290        }
291    }
292}
293
294impl ParameterMapping {
295    /// Applies this mapping to a raw value in the [0, 1] range.
296    pub fn apply(&self, raw: f64) -> f64 {
297        match self {
298            ParameterMapping::Linear => raw,
299            ParameterMapping::Exponential => raw * raw,
300            ParameterMapping::Logarithmic => (1.0 + raw * 9.0).log10(),
301            ParameterMapping::Inverted => 1.0 - raw,
302            ParameterMapping::Custom(f) => f(raw),
303        }
304    }
305}
306
307// =============================================================================
308// 6.5. Control context — stateful MIDI controller aggregation
309// =============================================================================
310
311/// Per-servo mutable context for stateful control events.
312///
313/// Pitch bend and mod wheel values accumulate here. When a note-on
314/// arrives, the servo composes the final frequency/amplitude from
315/// the context and sends it directly (bypassing mappings).
316#[derive(Debug, Clone)]
317pub(crate) struct ControlContext {
318    pitch_bend_semitones: f64,
319    mod_wheel: f64,
320    active_note: Option<u8>,
321    active_velocity: Option<f32>,
322}
323
324impl Default for ControlContext {
325    fn default() -> Self {
326        Self {
327            pitch_bend_semitones: 0.0,
328            mod_wheel: 1.0,
329            active_note: None,
330            active_velocity: None,
331        }
332    }
333}
334
335/// Convert a MIDI note number to frequency in Hz (A4 = 440 Hz).
336fn midi_note_to_freq(note: u8) -> f64 {
337    440.0 * 2.0f64.powf((note as f64 - 69.0) / 12.0)
338}
339
340// =============================================================================
341// 7. ServoState
342// =============================================================================
343
344/// Internal runtime state of a Servo, shared between the control actor and automation logic.
345pub(crate) struct ServoState<A: Automaton> {
346    /// Current automaton internal state.
347    pub(crate) internal: A::Internal,
348    /// Most recent output value produced by the automaton.
349    pub(crate) value: ParamValue,
350    /// Elapsed time in seconds since the automaton started.
351    pub(crate) time: Time,
352    /// Whether the servo is actively stepping the automaton.
353    pub(crate) enabled: bool,
354    /// Base value for modulation strategies (offset added to modulation output).
355    pub(crate) base: f64,
356    /// When `true`, the servo is frozen from UI touch (used with TouchOverride).
357    pub(crate) frozen: bool,
358    /// Last value sent to the graph, used for change detection.
359    pub(crate) last_sent_value: f64,
360    /// Last table index sent (only used with value tables).
361    pub(crate) last_sent_index: i64,
362    /// Stateful control context for pitch bend / mod wheel / note tracking.
363    pub(crate) control_ctx: ControlContext,
364}
365
366// =============================================================================
367// 8. Servo — automaton-to-parameter bridge
368// =============================================================================
369
370/// Bridges an automaton to a graph parameter, stepping on every clock tick and
371/// sending control commands to the signal graph.
372///
373/// Also accepts external control events (MIDI, OSC, CV/Gate) via
374/// `CommandEnum::Control`, applying registered [`Mapping`]s to convert
375/// them into `SetParameter` commands.
376pub struct Servo<A: Automaton> {
377    id: String,
378    automaton: Arc<A>,
379    pub(crate) state: Arc<Mutex<ServoState<A>>>,
380    graph_ref: ActorRef<CommandEnum>,
381    target_node: u32,
382    target_param: String,
383    mapping: ParameterMapping,
384    min: f64,
385    max: f64,
386    control: ControlStrategy,
387    conflict: ConflictStrategy,
388    table: Option<Vec<ParamValue>>,
389    /// Event-to-parameter mappings for sensor-driven control events.
390    mappings: Vec<Mapping>,
391    /// MIDI CC number for pitch bend (default 128 = pitch bend message).
392    pitch_bend_cc: Option<u8>,
393    /// Pitch bend range in semitones (±).
394    pitch_bend_semis: f64,
395    /// MIDI CC number for mod wheel (default 1).
396    mod_wheel_cc: Option<u8>,
397    /// String anchor name for rill-lang graph nodes. When set, parameter
398    /// commands use `SetParameter` with an anchor-prefixed parameter name.
399    target_anchor: Option<String>,
400}
401
402impl<A: Automaton + 'static> Servo<A> {
403    /// Creates a new Servo linking an automaton to a target parameter.
404    pub fn new(
405        id: impl Into<String>,
406        automaton: A,
407        target_node: u32,
408        target_param: impl Into<String>,
409        mapping: ParameterMapping,
410        min: f64,
411        max: f64,
412        system: Arc<ActorSystem>,
413        graph_ref: ActorRef<CommandEnum>,
414    ) -> Self {
415        let _ = system;
416        let automaton = Arc::new(automaton);
417        let mut internal = automaton.initial_internal();
418        let initial_value = automaton.step(
419            &mut internal,
420            &ParamValue::Float(0.0),
421            0.0,
422            &A::Action::default(),
423        );
424
425        Self {
426            id: id.into(),
427            automaton,
428            state: Arc::new(Mutex::new(ServoState {
429                internal,
430                value: initial_value,
431                time: 0.0,
432                enabled: true,
433                base: (min + max) / 2.0,
434                frozen: false,
435                last_sent_value: f64::NAN,
436                last_sent_index: -1,
437                control_ctx: ControlContext::default(),
438            })),
439            graph_ref,
440            target_node,
441            target_param: target_param.into(),
442            mapping,
443            min,
444            max,
445            control: ControlStrategy::Absolute,
446            conflict: ConflictStrategy::LastWriteWins,
447            table: None,
448            mappings: Vec::new(),
449            pitch_bend_cc: None,
450            pitch_bend_semis: 2.0,
451            mod_wheel_cc: None,
452            target_anchor: None,
453        }
454    }
455
456    /// Spawns this servo as a detached tokio actor, returning its address.
457    ///
458    /// The actor listens for `ClockTick` to step the automaton, and for
459    /// `AutomatonCommand` variants to handle enable/reset/UI value events.
460    pub fn spawn(self, system: &ActorSystem) -> ActorRef<CommandEnum> {
461        let Servo {
462            id,
463            automaton,
464            state,
465            graph_ref,
466            target_node,
467            target_param,
468            mapping,
469            min,
470            max,
471            control,
472            conflict,
473            table,
474            mappings,
475            pitch_bend_cc,
476            pitch_bend_semis,
477            mod_wheel_cc,
478            target_anchor,
479        } = self;
480
481        let a = automaton;
482        let s = state;
483        let gr = graph_ref;
484        let _nid = target_node;
485        let param = target_param;
486        let map = mapping;
487        let ctrl = control;
488        let confl = conflict;
489        let tbl = table;
490        let pitch_cc = pitch_bend_cc;
491        let pitch_semis = pitch_bend_semis;
492        let mod_cc = mod_wheel_cc;
493        let serv_id = id.clone();
494
495        let s2 = s.clone();
496        system.spawn_detached(
497            &format!("servo_{id}"),
498            move || {
499                Box::new(move |msg: CommandEnum| match msg {
500                    CommandEnum::ClockTick(clock) => {
501                        let mut state = s2.lock().unwrap();
502                        if !state.enabled {
503                            return;
504                        }
505                        let dt = clock.samples_since_last as f64 / clock.sample_rate as f64;
506                        state.time += dt;
507                        if state.frozen && matches!(confl, ConflictStrategy::TouchOverride) {
508                            return;
509                        }
510                        let current_value = state.value.clone();
511                        let current_time = state.time;
512                        let action = A::Action::default();
513                        let new_val =
514                            a.step(&mut state.internal, &current_value, current_time, &action);
515                        let raw = new_val.as_f32().unwrap_or(0.0) as f64;
516                        state.value = new_val;
517
518                        if let Some(ref table) = tbl {
519                            let index = raw as usize;
520                            if index >= table.len() {
521                                return;
522                            }
523                            let idx = index as i64;
524                            if idx == state.last_sent_index {
525                                return;
526                            }
527                            state.last_sent_index = idx;
528                            let sp = clock.sample_pos + clock.io_quantum as u64;
529                            if let Some(ref anchor) = target_anchor {
530                                let name = format!("{}.{}", anchor, param);
531                                let pid = ParameterId::new(&name).unwrap();
532                                gr.send(CommandEnum::SetParameter(
533                                    SetParameter::new(
534                                        "".to_string(),
535                                        pid,
536                                        table[index].clone(),
537                                        SignalOrigin::Automaton(serv_id.clone()),
538                                    )
539                                    .with_sample_pos(sp),
540                                ));
541                            } else {
542                                let pid = ParameterId::new(&param).unwrap();
543                                gr.send(CommandEnum::SetParameter(
544                                    SetParameter::new(
545                                        "".to_string(),
546                                        pid,
547                                        table[index].clone(),
548                                        SignalOrigin::Automaton(serv_id.clone()),
549                                    )
550                                    .with_sample_pos(sp),
551                                ));
552                            }
553                            return;
554                        }
555
556                        let mapped = map.apply(raw);
557                        let base = state.base;
558                        let value = match ctrl {
559                            ControlStrategy::Absolute => min + mapped * (max - min),
560                            ControlStrategy::Modulation { depth } => {
561                                (base + mapped * depth * (max - min)).clamp(min, max)
562                            }
563                        };
564                        if (value - state.last_sent_value).abs() < 1e-6 {
565                            return;
566                        }
567                        state.last_sent_value = value;
568
569                        // Skip SetParameter when no target parameter configured
570                        // (mapping-only servos with NoAction automaton).
571                        if param.is_empty() {
572                            return;
573                        }
574
575                        let sp = clock.sample_pos + clock.io_quantum as u64;
576                        if let Some(ref anchor) = target_anchor {
577                            let name = format!("{}.{}", anchor, param);
578                            let pid = ParameterId::new(&name).unwrap();
579                            gr.send(CommandEnum::SetParameter(
580                                SetParameter::new(
581                                    "".to_string(),
582                                    pid,
583                                    ParamValue::Float(value as f32),
584                                    SignalOrigin::Automaton(serv_id.clone()),
585                                )
586                                .with_sample_pos(sp),
587                            ));
588                        } else {
589                            let pid = ParameterId::new(&param).unwrap();
590                            gr.send(CommandEnum::SetParameter(
591                                SetParameter::new(
592                                    "".to_string(),
593                                    pid,
594                                    ParamValue::Float(value as f32),
595                                    SignalOrigin::Automaton(serv_id.clone()),
596                                )
597                                .with_sample_pos(sp),
598                            ));
599                        }
600                    }
601                    CommandEnum::Automaton(AutomatonCommand::SetEnabled { enabled, .. }) => {
602                        s.lock().unwrap().enabled = enabled;
603                    }
604                    CommandEnum::Automaton(AutomatonCommand::Reset { .. }) => {
605                        s.lock().unwrap().internal = a.reset();
606                    }
607                    CommandEnum::Automaton(AutomatonCommand::UiValue { value, .. }) => {
608                        let mut state = s.lock().unwrap();
609                        let should_send = match confl {
610                            ConflictStrategy::TouchOverride => {
611                                state.base = value;
612                                state.frozen = true;
613                                true
614                            }
615                            ConflictStrategy::BasePlusModulation => {
616                                state.base = value;
617                                false
618                            }
619                            ConflictStrategy::LastWriteWins => true,
620                        };
621                        if should_send {
622                            if let Some(ref anchor) = target_anchor {
623                                let name = format!("{}.{}", anchor, param);
624                                let pid = ParameterId::new(&name).unwrap();
625                                gr.send(CommandEnum::SetParameter(SetParameter::new(
626                                    "".to_string(),
627                                    pid,
628                                    ParamValue::Float(value as f32),
629                                    SignalOrigin::Automaton(serv_id.clone()),
630                                )));
631                            } else {
632                                let pid = ParameterId::new(&param).unwrap();
633                                gr.send(CommandEnum::SetParameter(SetParameter::new(
634                                    "".to_string(),
635                                    pid,
636                                    ParamValue::Float(value as f32),
637                                    SignalOrigin::Automaton(serv_id.clone()),
638                                )));
639                            }
640                        }
641                    }
642                    CommandEnum::Automaton(AutomatonCommand::UiRelease { .. }) => {
643                        let mut state = s.lock().unwrap();
644                        if state.frozen {
645                            state.frozen = false;
646                        }
647                    }
648                    CommandEnum::Control(event) => {
649                        match &event {
650                            // ── Pitch bend: update context, recalc if note active ──
651                            ControlEvent::MidiControl {
652                                controller,
653                                normalized,
654                                ..
655                            } if Some(*controller) == pitch_cc => {
656                                let mut state = s.lock().unwrap();
657                                let semis = (*normalized as f64 - 0.5) * 2.0 * pitch_semis;
658                                state.control_ctx.pitch_bend_semitones = semis;
659                                drop(state);
660
661                                let s3 = s.lock().unwrap();
662                                if let (Some(note), Some(_vel)) =
663                                    (s3.control_ctx.active_note, s3.control_ctx.active_velocity)
664                                {
665                                    let freq = midi_note_to_freq(note)
666                                        * 2.0f64.powf(s3.control_ctx.pitch_bend_semitones / 12.0);
667                                    let pid = ParameterId::new("frequency").unwrap();
668                                    gr.send(CommandEnum::SetParameter(SetParameter::new(
669                                        "".to_string(),
670                                        pid,
671                                        ParamValue::Float(freq as f32),
672                                        SignalOrigin::Automaton(serv_id.clone()),
673                                    )));
674                                }
675                                drop(s3);
676                            }
677                            // ── Mod wheel: update context, recalc if note active ──
678                            ControlEvent::MidiControl {
679                                controller,
680                                normalized,
681                                ..
682                            } if Some(*controller) == mod_cc => {
683                                let mut state = s.lock().unwrap();
684                                state.control_ctx.mod_wheel = *normalized as f64;
685                                drop(state);
686
687                                let s3 = s.lock().unwrap();
688                                if let (Some(_note), Some(vel)) =
689                                    (s3.control_ctx.active_note, s3.control_ctx.active_velocity)
690                                {
691                                    let amp = vel as f64 * s3.control_ctx.mod_wheel;
692                                    let pid = ParameterId::new("amplitude").unwrap();
693                                    gr.send(CommandEnum::SetParameter(SetParameter::new(
694                                        "".to_string(),
695                                        pid,
696                                        ParamValue::Float(amp as f32),
697                                        SignalOrigin::Automaton(serv_id.clone()),
698                                    )));
699                                }
700                                drop(s3);
701                            }
702                            // ── Note on: activate, compose from context ──
703                            ControlEvent::MidiNote {
704                                note,
705                                velocity,
706                                on: true,
707                                ..
708                            } if velocity > &0u8 => {
709                                let vel_norm = *velocity as f32 / 127.0;
710                                let mut state = s.lock().unwrap();
711                                state.control_ctx.active_note = Some(*note);
712                                state.control_ctx.active_velocity = Some(vel_norm);
713                                let freq = midi_note_to_freq(*note)
714                                    * 2.0f64.powf(state.control_ctx.pitch_bend_semitones / 12.0);
715                                let amp = vel_norm as f64 * state.control_ctx.mod_wheel;
716                                drop(state);
717
718                                // Send frequency
719                                let pid = ParameterId::new("frequency").unwrap();
720                                gr.send(CommandEnum::SetParameter(SetParameter::new(
721                                    "".to_string(),
722                                    pid,
723                                    ParamValue::Float(freq as f32),
724                                    SignalOrigin::Automaton(serv_id.clone()),
725                                )));
726                                // Send amplitude
727                                let pid_amp = ParameterId::new("amplitude").unwrap();
728                                gr.send(CommandEnum::SetParameter(SetParameter::new(
729                                    "".to_string(),
730                                    pid_amp,
731                                    ParamValue::Float(amp as f32),
732                                    SignalOrigin::Automaton(serv_id.clone()),
733                                )));
734                            }
735                            // ── Note off: deactivate, silence ──
736                            ControlEvent::MidiNote { on: false, .. } => {
737                                let mut state = s.lock().unwrap();
738                                state.control_ctx.active_note = None;
739                                state.control_ctx.active_velocity = None;
740                                drop(state);
741
742                                let pid = ParameterId::new("amplitude").unwrap();
743                                gr.send(CommandEnum::SetParameter(SetParameter::new(
744                                    "".to_string(),
745                                    pid,
746                                    ParamValue::Float(0.0),
747                                    SignalOrigin::Automaton(serv_id.clone()),
748                                )));
749                            }
750                            // ── Fallback: iterate user-defined mappings ──
751                            _ => {
752                                let mut state = s.lock().unwrap();
753                                for mapping in &mappings {
754                                    if let Some(sp) = mapping.apply(&event) {
755                                        match confl {
756                                            ConflictStrategy::TouchOverride => {
757                                                state.frozen = true;
758                                                if let Some(nv) = event.normalized_value() {
759                                                    state.base = nv as f64;
760                                                }
761                                                gr.send(CommandEnum::SetParameter(sp));
762                                                break; // one mapping match — freeze + send
763                                            }
764                                            ConflictStrategy::BasePlusModulation => {
765                                                if let Some(nv) = event.normalized_value() {
766                                                    state.base = nv as f64;
767                                                }
768                                                // Don't send SetParameter — automaton
769                                                // modulates around new base on next ClockTick.
770                                            }
771                                            ConflictStrategy::LastWriteWins => {
772                                                gr.send(CommandEnum::SetParameter(sp));
773                                            }
774                                        }
775                                    }
776                                }
777                            }
778                        }
779                    }
780                    _ => {}
781                })
782            },
783            1,
784        )
785    }
786
787    /// Attaches a preset value table; raw automaton output selects table entries by index.
788    pub fn with_table(mut self, table: Vec<ParamValue>) -> Self {
789        self.table = Some(table);
790        self
791    }
792
793    /// Enable pitch bend tracking via MIDI CC.
794    ///
795    /// When a pitch bend CC arrives and a note is active, the servo
796    /// recalculates frequency as `midi_to_freq(note) * 2^(bend/12)`.
797    pub fn with_pitch_bend(mut self, cc: u8, semitones: f64) -> Self {
798        self.pitch_bend_cc = Some(cc);
799        self.pitch_bend_semis = semitones;
800        self
801    }
802
803    /// Enable mod wheel tracking via MIDI CC.
804    ///
805    /// When a mod wheel CC arrives and a note is active, the servo
806    /// recalculates amplitude as `(velocity/127) * mod_wheel`.
807    pub fn with_mod_wheel(mut self, cc: u8) -> Self {
808        self.mod_wheel_cc = Some(cc);
809        self
810    }
811
812    /// Attaches sensor event mappings for [`Control`](CommandEnum::Control) dispatch.
813    ///
814    /// When the servo receives a `ControlEvent`, each mapping is checked;
815    /// matching events produce `SetParameter` commands sent to the graph.
816    pub fn with_mappings(mut self, mappings: Vec<Mapping>) -> Self {
817        self.mappings = mappings;
818        self
819    }
820
821    /// Set the control strategy — how the automaton affects the parameter value.
822    ///
823    /// - `Absolute` (default): automaton output `[0,1]` maps to `[min,max]`.
824    /// - `Modulation { depth }`: automaton output [-1,1] modulates around `base`.
825    pub fn with_control(mut self, strategy: ControlStrategy) -> Self {
826        self.control = strategy;
827        self
828    }
829
830    /// Set the conflict resolution strategy — how UI/HID input interacts with
831    /// automaton control for the same parameter.
832    ///
833    /// - `LastWriteWins` (default): both sources send independently; mailbox order.
834    /// - `TouchOverride`: HID input freezes automaton until `UiRelease`.
835    /// - `BasePlusModulation`: HID input sets the base value; automaton modulates around it.
836    pub fn with_conflict(mut self, strategy: ConflictStrategy) -> Self {
837        self.conflict = strategy;
838        self
839    }
840
841    /// Set the string anchor for rill-lang graph targeting.
842    ///
843    /// When set, parameter commands use `SetParameter` with an anchor-prefixed
844    /// parameter name (`anchor.param`).
845    pub fn with_anchor(mut self, anchor: String) -> Self {
846        self.target_anchor = Some(anchor);
847        self
848    }
849
850    /// Returns this servo's unique identifier.
851    pub fn id(&self) -> &str {
852        &self.id
853    }
854}
855
856#[cfg(feature = "debug")]
857impl<A: Automaton + 'static> Servo<A> {
858    /// Return an inspector that can snapshot this servo's automaton state.
859    pub fn inspector(&self) -> Box<dyn crate::debug::AutomatonInspector> {
860        Box::new(crate::debug::ServoInspector {
861            name: self.id.clone(),
862            state: self.state.clone(),
863        })
864    }
865}
866
867// =============================================================================
868// 9. Module trait — unified interface for sensors
869// =============================================================================
870
871/// Type-erased, heap-allocated reference to any module.
872pub type BoxedModule = Box<dyn Module>;
873
874/// Unified interface for sensor and control modules (MIDI hubs, OSC servers, etc.).
875pub trait Module: Send {
876    /// Returns this module's unique identifier.
877    fn id(&self) -> &str;
878    /// Returns the actor handle if this module has a control actor, `None` otherwise.
879    fn handle(&self) -> Option<ActorRef<CommandEnum>> {
880        None
881    }
882    /// Enables or disables the module.
883    fn set_enabled(&mut self, _enabled: bool) {}
884    /// Stops the module, joining any background threads.
885    fn stop(&mut self);
886}
887
888// =============================================================================
889// 10. Helper constructors
890// =============================================================================
891
892/// Convenience constructor for a MIDI control change mapping.
893pub fn midi_cc(
894    controller: u8,
895    channel: Option<u8>,
896    target_node: u32,
897    target_param: &str,
898    min: f32,
899    max: f32,
900    transform: Transform,
901) -> Mapping {
902    Mapping::new(
903        EventPattern::MidiControl {
904            channel,
905            controller,
906        },
907        Target {
908            node_id: target_node,
909            param_name: target_param.to_string(),
910            min,
911            max,
912        },
913        transform,
914    )
915}
916
917/// Convenience constructor for a MIDI note mapping.
918///
919/// Use [`MidiNoteKind`] to select which aspect of the note event to extract:
920/// - `Frequency` — `midi_to_freq(note)`, Note Off produces no value
921/// - `Amplitude` — `velocity / 127` (On) or `0.0` (Off)
922/// - `Gate` — `1.0` (On) or `0.0` (Off)
923pub fn midi_note(
924    kind: MidiNoteKind,
925    note: Option<u8>,
926    channel: Option<u8>,
927    target_node: u32,
928    target_param: &str,
929    min: f32,
930    max: f32,
931    transform: Transform,
932) -> Mapping {
933    Mapping::new(
934        EventPattern::MidiNote {
935            channel,
936            note,
937            kind,
938        },
939        Target {
940            node_id: target_node,
941            param_name: target_param.to_string(),
942            min,
943            max,
944        },
945        transform,
946    )
947}
948
949/// Convenience constructor for an OSC address mapping.
950pub fn osc_address(
951    address: &str,
952    target_node: u32,
953    target_param: &str,
954    min: f32,
955    max: f32,
956    transform: Transform,
957) -> Mapping {
958    Mapping::new(
959        EventPattern::OscAddress(address.to_string()),
960        Target {
961            node_id: target_node,
962            param_name: target_param.to_string(),
963            min,
964            max,
965        },
966        transform,
967    )
968}
969
970// =============================================================================
971// 11. Tests
972// =============================================================================
973
974#[cfg(test)]
975mod tests {
976    use super::*;
977
978    #[test]
979    fn test_midi_mapping() {
980        let node = 1;
981        let mapping = midi_cc(7, Some(1), node, "volume", 0.0, 1.0, Transform::Linear);
982        let event = ControlEvent::MidiControl {
983            channel: 1,
984            controller: 7,
985            value: 64,
986            normalized: 0.5,
987        };
988        assert!(mapping.matches(&event));
989        let cmd = mapping.apply(&event).unwrap();
990        assert_eq!(cmd.parameter.as_ref(), "volume");
991        assert!((cmd.value.as_f32().unwrap() - 0.5).abs() < 1e-6);
992    }
993}