Skip to main content

rill_patchbay/
control.rs

1//! # Управление и автоматизация (Control + Automation)
2//!
3//! `rill-patchbay::control` объединяет функциональность:
4//! - Маппинг событий (MIDI/OSC) на параметры узлов (из rill-control)
5//! - Автоматизацию через LFO, огибающие и другие генераторы (из rill-automation)
6//! - Двухпоточную модель с неблокирующими очередями
7//!
8//! Все операции выполняются в **потоке управления** (soft RT) и
9//! отправляют команды в аудиопоток через `RtQueue<ParameterCommand>`.
10
11use std::collections::HashMap;
12use std::fmt::Debug;
13use std::sync::Arc;
14
15use rill_core::prelude::*;
16use rill_core::queues::MpscQueue;
17
18pub use crate::automaton::Range;
19use crate::automaton::{EnvelopeAutomaton, LfoAutomaton, LfoWaveform};
20
21// =============================================================================
22// 1. Паттерны событий (из rill-control)
23// =============================================================================
24
25/// Паттерн для сопоставления событий
26#[derive(Debug, Clone, PartialEq, Eq, Hash)]
27pub enum EventPattern {
28    /// Любая кнопка
29    AnyButton,
30    /// Кнопка с конкретным ID
31    ButtonId(u32),
32
33    /// Любая ручка
34    AnyKnob,
35    /// Ручка с конкретным ID
36    KnobId(u32),
37
38    /// Любой фейдер
39    AnyFader,
40    /// Фейдер с конкретным ID
41    FaderId(u32),
42
43    /// Любое MIDI сообщение
44    AnyMidi,
45    /// MIDI Control Change
46    MidiControl { channel: Option<u8>, controller: u8 },
47    /// MIDI Note
48    MidiNote {
49        channel: Option<u8>,
50        note: Option<u8>,
51    },
52
53    /// OSC сообщение по адресу
54    OscAddress(String),
55
56    /// OSC с паттерном (содержит)
57    OscPattern(String),
58}
59
60impl EventPattern {
61    /// Проверить, соответствует ли событие паттерну
62    pub fn matches(&self, event: &ControlEvent) -> bool {
63        match (self, event) {
64            (EventPattern::AnyButton, ControlEvent::Button { .. }) => true,
65            (EventPattern::ButtonId(id), ControlEvent::Button { id: eid, .. }) => *id == *eid,
66
67            (EventPattern::AnyKnob, ControlEvent::Knob { .. }) => true,
68            (EventPattern::KnobId(id), ControlEvent::Knob { id: eid, .. }) => *id == *eid,
69
70            (EventPattern::AnyFader, ControlEvent::Fader { .. }) => true,
71            (EventPattern::FaderId(id), ControlEvent::Fader { id: eid, .. }) => *id == *eid,
72
73            (
74                EventPattern::MidiControl {
75                    channel,
76                    controller,
77                },
78                ControlEvent::MidiControl {
79                    channel: ech,
80                    controller: ectr,
81                    ..
82                },
83            ) => (channel.is_none() || channel.unwrap() == *ech) && *controller == *ectr,
84
85            (EventPattern::OscAddress(addr), ControlEvent::Osc { address, .. }) => addr == address,
86
87            (EventPattern::OscPattern(pat), ControlEvent::Osc { address, .. }) => {
88                address.contains(pat)
89            }
90
91            _ => false,
92        }
93    }
94}
95
96// =============================================================================
97// 2. Типы событий (из rill-control)
98// =============================================================================
99
100/// Событие контроллера
101#[derive(Debug, Clone, PartialEq)]
102pub enum ControlEvent {
103    /// Кнопка (нажата/отпущена)
104    Button { id: u32, pressed: bool },
105
106    /// Поворотная ручка (энкодер)
107    Knob {
108        id: u32,
109        value: f32,      // 0.0 - 1.0
110        normalized: f32, // то же, для совместимости
111    },
112
113    /// Фейдер (линейный ползунок)
114    Fader {
115        id: u32,
116        value: f32, // 0.0 - 1.0
117        normalized: f32,
118    },
119
120    /// MIDI Control Change
121    MidiControl {
122        channel: u8,
123        controller: u8,
124        value: u8,       // 0-127
125        normalized: f32, // 0.0 - 1.0
126    },
127
128    /// MIDI Note
129    MidiNote {
130        channel: u8,
131        note: u8,
132        velocity: u8,
133        on: bool,
134    },
135
136    /// OSC сообщение
137    Osc { address: String, args: Vec<f32> },
138}
139
140impl ControlEvent {
141    /// Получить нормализованное значение (0.0-1.0), если применимо
142    pub fn normalized_value(&self) -> Option<f32> {
143        match self {
144            ControlEvent::Knob { normalized, .. } => Some(*normalized),
145            ControlEvent::Fader { normalized, .. } => Some(*normalized),
146            ControlEvent::MidiControl { normalized, .. } => Some(*normalized),
147            ControlEvent::Button { pressed, .. } => Some(if *pressed { 1.0 } else { 0.0 }),
148            _ => None,
149        }
150    }
151
152    /// Получить ID элемента управления, если применимо
153    pub fn id(&self) -> Option<u32> {
154        match self {
155            ControlEvent::Button { id, .. } => Some(*id),
156            ControlEvent::Knob { id, .. } => Some(*id),
157            ControlEvent::Fader { id, .. } => Some(*id),
158            _ => None,
159        }
160    }
161}
162
163// =============================================================================
164// 3. Трансформации значений (из rill-control)
165// =============================================================================
166
167/// Тип преобразования значения
168#[derive(Clone)]
169pub enum Transform {
170    /// Линейное: out = min + value * (max - min)
171    Linear,
172
173    /// Экспоненциальное: out = min + value^2 * (max - min)
174    Exponential,
175
176    /// Логарифмическое: out = min + log(1 + value * 9) / log(10) * (max - min)
177    Logarithmic,
178
179    /// Инвертированное: out = max - value * (max - min)
180    Inverted,
181
182    /// Пользовательское
183    Custom(Arc<dyn Fn(f32) -> f32 + Send + Sync>),
184}
185
186impl Debug for Transform {
187    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188        match self {
189            Transform::Linear => write!(f, "Linear"),
190            Transform::Exponential => write!(f, "Exponential"),
191            Transform::Logarithmic => write!(f, "Logarithmic"),
192            Transform::Inverted => write!(f, "Inverted"),
193            Transform::Custom(_) => write!(f, "Custom"),
194        }
195    }
196}
197
198impl Transform {
199    /// Применить преобразование к нормализованному значению (0-1)
200    pub fn apply(&self, value: f32, min: f32, max: f32) -> f32 {
201        let range = max - min;
202        let normalized = value.clamp(0.0, 1.0);
203
204        let mapped = match self {
205            Transform::Linear => min + normalized * range,
206            Transform::Exponential => min + normalized * normalized * range,
207            Transform::Logarithmic => min + (1.0 + normalized * 9.0).log10() * range,
208            Transform::Inverted => max - normalized * range,
209            Transform::Custom(f) => min + f(normalized) * range,
210        };
211
212        mapped.clamp(min, max)
213    }
214}
215
216// =============================================================================
217// 4. Маппинг событий (из rill-control)
218// =============================================================================
219
220/// Целевой параметр узла
221#[derive(Debug, Clone)]
222pub struct Target {
223    /// ID узла в графе
224    pub node_id: NodeId,
225    /// Имя параметра
226    pub param_name: String,
227    /// Минимальное значение
228    pub min: f32,
229    /// Максимальное значение
230    pub max: f32,
231}
232
233/// Маппинг события на параметр
234#[derive(Debug, Clone)]
235pub struct Mapping {
236    /// Паттерн события
237    pub pattern: EventPattern,
238    /// Целевой параметр
239    pub target: Target,
240    /// Преобразование
241    pub transform: Transform,
242    /// Название (для отладки)
243    pub name: String,
244    /// Активен ли маппинг
245    pub enabled: bool,
246}
247
248impl Mapping {
249    /// Создать новый маппинг
250    pub fn new(pattern: EventPattern, target: Target, transform: Transform) -> Self {
251        let name = format!("{:?} -> {}", pattern, target.param_name);
252        Self {
253            pattern,
254            target,
255            transform,
256            name,
257            enabled: true,
258        }
259    }
260
261    /// Проверить, подходит ли событие под этот маппинг
262    pub fn matches(&self, event: &ControlEvent) -> bool {
263        self.enabled && self.pattern.matches(event)
264    }
265
266    /// Применить событие и получить команду для параметра
267    pub fn apply(&self, event: &ControlEvent) -> Option<ParameterCommand> {
268        if !self.matches(event) {
269            return None;
270        }
271
272        event.normalized_value().map(|norm| {
273            let value = self.transform.apply(norm, self.target.min, self.target.max);
274            ParameterCommand {
275                node_id: self.target.node_id,
276                param: self.target.param_name.clone(),
277                value,
278            }
279        })
280    }
281}
282
283// =============================================================================
284// 5. Автоматы (из rill-automation)
285// =============================================================================
286
287/// Тип времени для автоматов
288pub type Time = f64;
289
290/// Маркер "нет действия" (для автоматов без внешнего управления)
291#[derive(Debug, Clone, Default)]
292pub struct NoAction;
293
294/// Базовый трейт для всех автоматов
295///
296/// Автомат — это контейнер/исполнитель для чистой функции (`Action`),
297/// которая применяется к изменяемому состоянию (`State`) на каждом шаге.
298/// Автомат управляет диапазоном значений, интерполяцией и прочими
299/// аспектами выполнения, а `Action` — это чистое вычисление.
300pub trait Automaton: Send + Sync + Debug {
301    /// Тип состояния
302    type State: Clone + Send + Sync + 'static + Debug;
303
304    /// Тип действия (чистая функция, применяемая к состоянию)
305    type Action: Debug + Clone + Send + Sync + Default + 'static;
306
307    /// Выполнить один шаг автомата
308    ///
309    /// # Arguments
310    /// * `time` — текущее время
311    /// * `action` — действие/функция, применяемая к состоянию
312    /// * `state` — текущее состояние
313    ///
314    /// Возвращает (новое_состояние, опциональное_значение)
315    fn step(
316        &self,
317        time: Time,
318        action: &Self::Action,
319        state: &Self::State,
320    ) -> (Self::State, Option<f64>);
321
322    /// Начальное состояние
323    fn initial_state(&self) -> Self::State;
324
325    /// Имя автомата
326    fn name(&self) -> &str;
327
328    /// Извлечь значение из состояния
329    fn extract_value(&self, state: &Self::State) -> f64;
330
331    /// Сбросить автомат (создать новое начальное состояние)
332    fn reset(&self) -> Self::State {
333        self.initial_state()
334    }
335}
336
337// =============================================================================
338// 6. Сервоприводы (связь автоматов с параметрами)
339// =============================================================================
340
341// =============================================================================
342// 6. Сервоприводы (связь автоматов с параметрами)
343// =============================================================================
344
345/// Тип маппинга значений для сервопривода
346#[derive(Clone)]
347pub enum ParameterMapping {
348    Linear,
349    Exponential,
350    Logarithmic,
351    Inverted,
352    Custom(Arc<dyn Fn(f64) -> f64 + Send + Sync>),
353}
354
355impl std::fmt::Debug for ParameterMapping {
356    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
357        match self {
358            ParameterMapping::Linear => write!(f, "Linear"),
359            ParameterMapping::Exponential => write!(f, "Exponential"),
360            ParameterMapping::Logarithmic => write!(f, "Logarithmic"),
361            ParameterMapping::Inverted => write!(f, "Inverted"),
362            ParameterMapping::Custom(_) => write!(f, "Custom(<fn>)"),
363        }
364    }
365}
366
367impl ParameterMapping {
368    pub fn apply(&self, raw: f64) -> f64 {
369        match self {
370            ParameterMapping::Linear => raw,
371            ParameterMapping::Exponential => raw * raw,
372            ParameterMapping::Logarithmic => (1.0 + raw * 9.0).log10(),
373            ParameterMapping::Inverted => 1.0 - raw,
374            ParameterMapping::Custom(f) => f(raw),
375        }
376    }
377}
378
379/// Сервопривод — связывает автомат с параметром узла
380pub struct Servo<A: Automaton> {
381    /// Идентификатор
382    id: String,
383    /// Автомат
384    automaton: A,
385    /// Состояние автомата
386    state: A::State,
387    /// Целевой узел
388    target_node: NodeId,
389    /// Целевой параметр
390    target_param: String,
391    /// Маппинг значения
392    mapping: ParameterMapping,
393    /// Минимальное значение
394    min: f64,
395    /// Максимальное значение
396    max: f64,
397    /// Последнее отправленное значение
398    last_value: f64,
399    /// Активен ли сервопривод
400    enabled: bool,
401    /// Время последнего обновления
402    last_time: Time,
403}
404
405impl<A: Automaton> Servo<A> {
406    pub fn new(
407        id: impl Into<String>,
408        automaton: A,
409        target_node: NodeId,
410        target_param: impl Into<String>,
411        mapping: ParameterMapping,
412        min: f64,
413        max: f64,
414    ) -> Self {
415        let state = automaton.initial_state();
416        Self {
417            id: id.into(),
418            automaton,
419            state,
420            target_node,
421            target_param: target_param.into(),
422            mapping,
423            min,
424            max,
425            last_value: 0.0,
426            enabled: true,
427            last_time: 0.0,
428        }
429    }
430
431    /// Обновить сервопривод и вернуть команду, если значение изменилось
432    pub fn update(&mut self, time: Time) -> Option<ParameterCommand> {
433        if !self.enabled {
434            return None;
435        }
436
437        let (new_state, value_opt) = self
438            .automaton
439            .step(time, &A::Action::default(), &self.state);
440        self.state = new_state;
441
442        if let Some(raw_value) = value_opt {
443            let mapped = self.mapping.apply(raw_value);
444            let clamped = mapped.clamp(self.min, self.max);
445
446            // Отправляем только если значение изменилось значительно
447            if (clamped - self.last_value).abs() > 1e-6 {
448                self.last_value = clamped;
449                self.last_time = time;
450
451                return Some(ParameterCommand {
452                    node_id: self.target_node,
453                    param: self.target_param.clone(),
454                    value: clamped as f32,
455                });
456            }
457        }
458
459        None
460    }
461
462    /// Включить/выключить сервопривод
463    pub fn set_enabled(&mut self, enabled: bool) {
464        self.enabled = enabled;
465    }
466
467    /// Получить ID сервопривода
468    pub fn id(&self) -> &str {
469        &self.id
470    }
471}
472
473// Тип для хранения разнородных сервоприводов
474pub type BoxedServo = Box<dyn AnyServo>;
475
476pub trait AnyServo: Send + Sync {
477    fn update(&mut self, time: Time) -> Option<ParameterCommand>;
478    fn id(&self) -> &str;
479    fn set_enabled(&mut self, enabled: bool);
480}
481
482impl<A: Automaton + 'static> AnyServo for Servo<A> {
483    fn update(&mut self, time: Time) -> Option<ParameterCommand> {
484        Servo::update(self, time)
485    }
486
487    fn id(&self) -> &str {
488        &self.id
489    }
490
491    fn set_enabled(&mut self, enabled: bool) {
492        self.enabled = enabled;
493    }
494}
495
496// =============================================================================
497// 7. Команды для аудиопотока
498// =============================================================================
499
500/// Команда изменения параметра (отправляется в аудиопоток)
501#[derive(Debug, Clone)]
502pub struct ParameterCommand {
503    /// ID узла
504    pub node_id: NodeId,
505    /// Имя параметра
506    pub param: String,
507    /// Новое значение
508    pub value: f32,
509}
510
511impl ParameterCommand {
512    /// Создать новую команду
513    pub fn new(node_id: NodeId, param: impl Into<String>, value: f32) -> Self {
514        Self {
515            node_id,
516            param: param.into(),
517            value,
518        }
519    }
520}
521
522// =============================================================================
523// 8. Главный контроллер (Patchbay Control)
524// =============================================================================
525
526/// Главный контроллер патчбэя
527///
528/// Работает в **потоке управления** (soft RT) и отправляет команды
529/// в аудиопоток через `MpscQueue<ParameterCommand>`.
530pub struct PatchbayControl {
531    /// Маппинги событий
532    mappings: Vec<Mapping>,
533
534    /// Сервоприводы (автоматы)
535    servos: HashMap<String, BoxedServo>,
536
537    /// Очередь для отправки команд в аудиопоток
538    command_queue: Arc<MpscQueue<ParameterCommand>>,
539
540    /// Внутреннее время (секунды)
541    time: Time,
542}
543
544impl PatchbayControl {
545    /// Создать новый контроллер
546    pub fn new(command_queue: Arc<MpscQueue<ParameterCommand>>) -> Self {
547        Self {
548            mappings: Vec::new(),
549            servos: HashMap::new(),
550            command_queue,
551            time: 0.0,
552        }
553    }
554
555    /// Добавить маппинг события
556    pub fn add_mapping(&mut self, mapping: Mapping) {
557        self.mappings.push(mapping);
558    }
559
560    /// Добавить маппинг из строк (удобно для скриптов)
561    pub fn add_mapping_str(
562        &mut self,
563        pattern: &str,
564        target_node: NodeId,
565        target_param: &str,
566        min: f32,
567        max: f32,
568        transform: Transform,
569    ) -> Result<(), &'static str> {
570        let pattern = match pattern {
571            p if p.starts_with("button:") => {
572                let id = p[7..].parse().map_err(|_| "Invalid button ID")?;
573                EventPattern::ButtonId(id)
574            }
575            p if p.starts_with("knob:") => {
576                let id = p[5..].parse().map_err(|_| "Invalid knob ID")?;
577                EventPattern::KnobId(id)
578            }
579            p if p.starts_with("fader:") => {
580                let id = p[6..].parse().map_err(|_| "Invalid fader ID")?;
581                EventPattern::FaderId(id)
582            }
583            p if p.starts_with("midi:") => {
584                let parts: Vec<&str> = p[5..].split(':').collect();
585                if parts.len() == 2 {
586                    let channel = parts[0].parse().ok();
587                    let controller = parts[1].parse().map_err(|_| "Invalid controller")?;
588                    EventPattern::MidiControl {
589                        channel,
590                        controller,
591                    }
592                } else {
593                    EventPattern::AnyMidi
594                }
595            }
596            p if p.starts_with("osc:") => EventPattern::OscAddress(p[4..].to_string()),
597            _ => return Err("Unknown pattern"),
598        };
599
600        let target = Target {
601            node_id: target_node,
602            param_name: target_param.to_string(),
603            min,
604            max,
605        };
606
607        self.add_mapping(Mapping::new(pattern, target, transform));
608        Ok(())
609    }
610
611    /// Добавить сервопривод (автомат)
612    pub fn add_servo<A: Automaton + 'static>(&mut self, servo: Servo<A>) {
613        self.servos.insert(servo.id().to_string(), Box::new(servo));
614    }
615
616    /// Добавить LFO как сервопривод
617    pub fn add_lfo(
618        &mut self,
619        id: &str,
620        frequency: f64,
621        amplitude: f64,
622        offset: f64,
623        waveform: LfoWaveform,
624        target_node: NodeId,
625        target_param: &str,
626        min: f64,
627        max: f64,
628    ) {
629        let automaton = LfoAutomaton::new(id, frequency, amplitude, offset, waveform);
630        let servo = Servo::new(
631            id,
632            automaton,
633            target_node,
634            target_param,
635            ParameterMapping::Linear,
636            min,
637            max,
638        );
639        self.add_servo(servo);
640    }
641
642    /// Добавить огибающую как сервопривод
643    pub fn add_envelope(
644        &mut self,
645        id: &str,
646        attack: f64,
647        decay: f64,
648        sustain: f64,
649        release: f64,
650        target_node: NodeId,
651        target_param: &str,
652        min: f64,
653        max: f64,
654    ) {
655        let automaton = EnvelopeAutomaton::adsr(id, attack, decay, sustain, release);
656        let servo = Servo::new(
657            id,
658            automaton,
659            target_node,
660            target_param,
661            ParameterMapping::Linear,
662            min,
663            max,
664        );
665        self.add_servo(servo);
666    }
667
668    /// Обработать внешнее событие (MIDI/OSC)
669    pub fn handle_event(&mut self, event: ControlEvent) {
670        for mapping in &self.mappings {
671            if let Some(cmd) = mapping.apply(&event) {
672                let _ = self.command_queue.push(cmd);
673            }
674        }
675    }
676
677    /// Обновить состояние (вызывается регулярно из потока управления)
678    pub fn update(&mut self, dt: f32) {
679        self.time += dt as f64;
680
681        // Обновляем все сервоприводы
682        for servo in self.servos.values_mut() {
683            if let Some(cmd) = servo.update(self.time) {
684                let _ = self.command_queue.push(cmd);
685            }
686        }
687    }
688
689    /// Получить все маппинги
690    pub fn mappings(&self) -> &[Mapping] {
691        &self.mappings
692    }
693
694    /// Получить сервопривод по ID
695    pub fn get_servo(&self, id: &str) -> Option<&dyn AnyServo> {
696        self.servos.get(id).map(|b| b.as_ref())
697    }
698
699    /// Получить мутабельный сервопривод по ID
700    pub fn get_servo_mut(&mut self, id: &str) -> Option<&mut BoxedServo> {
701        self.servos.get_mut(id)
702    }
703
704    /// Удалить сервопривод
705    pub fn remove_servo(&mut self, id: &str) -> bool {
706        self.servos.remove(id).is_some()
707    }
708
709    /// Очистить все маппинги и сервоприводы
710    pub fn clear(&mut self) {
711        self.mappings.clear();
712        self.servos.clear();
713    }
714
715    /// Сбросить время
716    pub fn reset_time(&mut self) {
717        self.time = 0.0;
718    }
719
720    /// Текущее время
721    pub fn current_time(&self) -> Time {
722        self.time
723    }
724}
725
726// =============================================================================
727// 9. Вспомогательные функции для создания маппингов
728// =============================================================================
729
730/// Создать маппинг MIDI CC на параметр
731pub fn midi_cc(
732    controller: u8,
733    channel: Option<u8>,
734    target_node: NodeId,
735    target_param: &str,
736    min: f32,
737    max: f32,
738    transform: Transform,
739) -> Mapping {
740    let pattern = EventPattern::MidiControl {
741        channel,
742        controller,
743    };
744    let target = Target {
745        node_id: target_node,
746        param_name: target_param.to_string(),
747        min,
748        max,
749    };
750    Mapping::new(pattern, target, transform)
751}
752
753/// Создать маппинг OSC адреса на параметр
754pub fn osc_address(
755    address: &str,
756    target_node: NodeId,
757    target_param: &str,
758    min: f32,
759    max: f32,
760    transform: Transform,
761) -> Mapping {
762    let pattern = EventPattern::OscAddress(address.to_string());
763    let target = Target {
764        node_id: target_node,
765        param_name: target_param.to_string(),
766        min,
767        max,
768    };
769    Mapping::new(pattern, target, transform)
770}
771
772// =============================================================================
773// 10. Тесты
774// =============================================================================
775
776#[cfg(test)]
777mod tests {
778    use super::*;
779    use rill_core::queues::MpscQueue;
780
781    #[test]
782    fn test_midi_mapping() {
783        let node = NodeId(1);
784        let mapping = midi_cc(7, Some(1), node, "volume", 0.0, 1.0, Transform::Linear);
785
786        let event = ControlEvent::MidiControl {
787            channel: 1,
788            controller: 7,
789            value: 64,
790            normalized: 0.5,
791        };
792
793        assert!(mapping.matches(&event));
794
795        let cmd = mapping.apply(&event).unwrap();
796        assert_eq!(cmd.node_id, node);
797        assert_eq!(cmd.param, "volume");
798        assert!((cmd.value - 0.5).abs() < 1e-6);
799    }
800
801    #[test]
802    fn test_lfo_servo() {
803        let node = NodeId(1);
804        let queue = Arc::new(MpscQueue::with_capacity(64));
805        let mut control = PatchbayControl::new(queue);
806
807        control.add_lfo(
808            "test_lfo",
809            1.0,
810            0.5,
811            0.0,
812            LfoWaveform::Sine,
813            node,
814            "cutoff",
815            100.0,
816            1000.0,
817        );
818
819        assert!(control.get_servo("test_lfo").is_some());
820
821        // Несколько обновлений должны генерировать команды
822        for _i in 0..10 {
823            control.update(0.1);
824        }
825    }
826
827    #[test]
828    fn test_envelope_servo() {
829        let node = NodeId(1);
830        let queue = Arc::new(MpscQueue::with_capacity(64));
831        let mut control = PatchbayControl::new(queue.clone());
832
833        control.add_envelope("test_env", 0.1, 0.2, 0.7, 0.3, node, "gain", 0.0, 1.0);
834
835        // Находим сервопривод и триггерим его
836        if let Some(_servo) = control.get_servo_mut("test_env") {
837            // В реальном коде здесь нужно вызвать trigger
838            // Для теста просто обновляем время
839        }
840
841        control.update(0.05);
842        control.update(0.05);
843
844        // Должны быть команды в очереди
845        // assert!(queue.len() > 0); // В реальном тесте
846    }
847}