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, ¤t_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(¶m).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(¶m).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(¶m).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}