Skip to main content

gled_core/engine/
mod.rs

1//! The engine: the one place that owns the running show. It holds the
2//! authoritative project, applies actions from the user interfaces, OSC, MIDI
3//! and Art-Net, evaluates the inputs, keeps time with Ableton Link, drives the
4//! render thread and the audio analysis, and publishes its state to every
5//! connected user interface.
6//!
7//! It runs on its own thread at a fixed tick rate, independent of any window.
8//! The desktop app talks to it through an `EngineHandle`; a remote client will
9//! talk to it the same way over the network.
10//!
11//! User interfaces change the show only through actions (see
12//! `ui::project_edits` for edits made directly in widgets). Their actions are
13//! numbered, and the engine reports the newest one it applied in
14//! `EngineState::applied_sequence`, so an interface knows when the engine's
15//! project includes its own edits.
16
17mod actions;
18mod applied_output;
19mod devices;
20mod event_log;
21#[cfg(any(test, feature = "test-util"))]
22mod fake;
23mod midi_tools;
24mod outbox;
25mod session;
26mod status;
27mod svg_layout;
28mod svg_templates;
29
30pub(crate) use event_log::{EventLog, InputLog, LearnLog, MidiLog};
31#[cfg(any(test, feature = "test-util"))]
32pub use fake::{FakeEngine, fake_engine};
33use gled_model::sound::trigger_ref::SoundLevels;
34use gled_protocol::bytes::SharedBytes;
35pub use gled_protocol::state::{
36    ClientInput, Devices, EngineEvent, EngineState, RenderStats, ServerSettings, ServerStatus,
37    SoundSnapshot, StorageCommand,
38};
39pub use outbox::Outbox;
40pub use session::{ClientConnection, ClientId, ClientState, EngineConnector};
41
42use std::{
43    collections::{BTreeSet, HashMap},
44    sync::Arc,
45    time::{Duration, Instant},
46};
47
48use kanal::{Receiver, unbounded};
49use tracing::debug;
50
51use crate::{
52    action::UiAction,
53    audio::{
54        AudioPool,
55        sound_data::SoundData,
56        sound_trigger_ref::{CollectSoundTriggers, SoundTriggerSet},
57    },
58    input::{
59        Input, external_control::ExternalControlState, osc::OSCHandler, osc::OscConfig,
60        project_inputs::ProjectInputs,
61    },
62    output_state::ProjectState,
63    pipeline::extract_output::{ExtractOutput, OutputConfig, PreviewOutput},
64    pipeline::output_sender::HOVERED_OUTPUT_ROUTING,
65    runtime::{
66        persistent_state::PersistentState,
67        render_thread::{RenderFeedback, RenderSnapshot, RenderThread},
68        timing::Timing,
69    },
70    storage::{
71        action::StorageAction,
72        asset::{Asset, project::Project, scene::Scene},
73        asset_id::AssetId,
74        collections::Collections,
75        is_loading,
76        store::{AssetStore, delete_asset},
77    },
78};
79use applied_output::AppliedOutput;
80use gled_model::{
81    asset::{AssetTrait, output_device::routing::OutputRouting, scene::grid::GridLocation},
82    collection::Collections as AssetCollections,
83};
84use gled_protocol::{
85    assets::{AssetIdVisitor, AssetVisitor},
86    midi::{MidiMonitorEvent, TestCommand},
87};
88use svg_layout::SvgLayout;
89use uuid::Uuid;
90
91/// The engine's tick rate. Inputs are evaluated and state is published once
92/// per tick; the LED output runs at its own rate on the render thread.
93///
94/// 8333 us per tick is about 120 updates per second.
95const TICK: Duration = Duration::from_micros(8_333);
96/// Sound levels are published every this many ticks, 30 times a second.
97const SOUND_EVERY_TICKS: u32 = 4;
98
99/// What the engine needs from its creator.
100#[derive(Debug)]
101pub(crate) struct EngineConfig {
102    /// GPU adapter for the render thread's output device.
103    pub(crate) adapter: wgpu::Adapter,
104    pub(crate) extract_output: ExtractOutput,
105    pub(crate) artnet_control_receiver: Receiver<Vec<u8>>,
106    /// The rendered output, read back for the preview.
107    pub(crate) preview_output: Receiver<PreviewOutput>,
108    /// Art-Net and DMX traffic in Mbit/s, in and out.
109    pub(crate) network_stats: Receiver<(f64, f64)>,
110    /// The machine's hottest temperature sensor in °C, once a second.
111    pub(crate) temperature: Receiver<Option<f32>>,
112    pub(crate) midi_monitor: Receiver<MidiMonitorEvent>,
113    pub(crate) midi_learn: Receiver<[u8; 3]>,
114    pub(crate) midi_test: kanal::Sender<TestCommand>,
115    pub(crate) audio_pool: AudioPool,
116}
117
118/// Starts the engine thread. It runs until every connector and connection is
119/// dropped.
120pub(crate) fn spawn(
121    config: EngineConfig,
122    actions: Receiver<UiAction>,
123) -> anyhow::Result<EngineConnector> {
124    let timing = Timing::default();
125    let persistent_state = PersistentState::default();
126    let render_thread = RenderThread::spawn(
127        &config.adapter,
128        timing.link(),
129        config.extract_output,
130        persistent_state.fps_limit(),
131    )?;
132
133    let (input_sender, input_receiver) = unbounded();
134
135    let engine = Engine {
136        startup: true,
137        timing,
138        project: None,
139        project_id: None,
140        selected_scene_instance: GridLocation::default(),
141        blackout: true,
142        blackout_hold: false,
143        midi_output_active: false,
144        actions,
145        inputs: input_receiver,
146        sessions: HashMap::new(),
147        collections: Collections::default(),
148        persistent_state,
149        output_config: OutputConfig::default(),
150        render_thread,
151        external_control_state: ExternalControlState::new(config.artnet_control_receiver),
152        audio_pool: config.audio_pool,
153        sound_data: SoundData::default(),
154        osc_handler: None,
155        osc_config: None,
156        applied_output: AppliedOutput::default(),
157        hovered_routing: None,
158        input_log: Arc::default(),
159        device_scan: devices::DeviceScan::default(),
160        svg_templates: svg_templates::SvgTemplates::default(),
161        watched_triggers: HashMap::new(),
162        midi: midi_tools::MidiTools::new(config.midi_monitor, config.midi_learn, config.midi_test),
163        svg_layout: SvgLayout::default(),
164        render_stats: RenderStats::default(),
165        published_assets: (u64::MAX, Arc::default()),
166        preview_output: config.preview_output,
167        network_stats_receiver: config.network_stats,
168        network_stats: (0.0, 0.0),
169        temperature_receiver: config.temperature,
170        temperature: None,
171        host_name: gethostname::gethostname().to_string_lossy().into_owned(),
172        sound: Arc::default(),
173        ticks_since_sound: 0,
174        preview: None,
175    };
176
177    std::thread::Builder::new()
178        .name("gled:engine".to_string())
179        .spawn(move || engine.run())?;
180
181    Ok(EngineConnector {
182        inputs: input_sender,
183        next_id: Arc::default(),
184    })
185}
186
187pub(crate) struct Engine {
188    /// Load the last project once storage has finished loading.
189    startup: bool,
190    timing: Timing,
191    project: Option<Project>,
192    project_id: Option<AssetId<Project>>,
193    selected_scene_instance: GridLocation,
194    blackout: bool,
195    blackout_hold: bool,
196    midi_output_active: bool,
197    actions: Receiver<UiAction>,
198    inputs: Receiver<session::EngineInput>,
199    sessions: HashMap<ClientId, session::Session>,
200    collections: Collections,
201    persistent_state: PersistentState,
202    output_config: OutputConfig,
203    render_thread: RenderThread,
204    external_control_state: ExternalControlState,
205    audio_pool: AudioPool,
206    sound_data: SoundData,
207    osc_handler: Option<Arc<OSCHandler>>,
208    /// The OSC settings the handler runs with.
209    osc_config: Option<OscConfig>,
210    /// The project's output settings as last applied to the running output.
211    applied_output: AppliedOutput,
212    device_scan: devices::DeviceScan,
213    svg_templates: svg_templates::SvgTemplates,
214    /// The sound triggers each interface edits.
215    watched_triggers: HashMap<ClientId, BTreeSet<Uuid>>,
216    midi: midi_tools::MidiTools,
217    /// The input events of the last ticks, for assigning inputs.
218    input_log: Arc<InputLog>,
219    /// The output routing an interface points at, and which one.
220    hovered_routing: Option<(ClientId, OutputRouting)>,
221    svg_layout: SvgLayout,
222    render_stats: RenderStats,
223    preview_output: Receiver<PreviewOutput>,
224    network_stats_receiver: Receiver<(f64, f64)>,
225    network_stats: (f64, f64),
226    temperature_receiver: Receiver<Option<f32>>,
227    temperature: Option<f32>,
228    /// This machine's name, which interfaces on others show it under.
229    host_name: String,
230    sound: Arc<SoundSnapshot>,
231    ticks_since_sound: u32,
232    /// The newest preview frame; `None` while no project renders.
233    preview: Option<PreviewOutput>,
234    /// The assets as last published, and the replica's generation then.
235    published_assets: (u64, Arc<AssetCollections>),
236}
237
238impl Engine {
239    fn run(mut self) {
240        let mut next_tick = Instant::now();
241        loop {
242            if !self.tick() {
243                debug!("All user interfaces are gone, stopping the engine");
244                return;
245            }
246            next_tick += TICK;
247            let now = Instant::now();
248            match next_tick.checked_duration_since(now) {
249                Some(wait) => std::thread::sleep(wait),
250                // Behind schedule, e.g. after loading a project: start over
251                // instead of running a burst of ticks.
252                None => next_tick = now,
253            }
254        }
255    }
256
257    /// One engine step. Returns `false` once no user interface is connected.
258    #[cfg_attr(feature = "profiling", profiling::function)]
259    fn tick(&mut self) -> bool {
260        self.receive_render_feedback();
261        self.persistent_state.update();
262        self.sound_data.update();
263        self.timing.tick(&self.persistent_state);
264        self.collections.update();
265        self.sync_sound_triggers();
266
267        if !is_loading() && self.startup {
268            self.startup = false;
269            if let Some(project) = self.persistent_state.last_project_id() {
270                UiAction::SetProject(project).enqueue();
271            }
272        }
273
274        if !self.receive_client_inputs() {
275            return false;
276        }
277        Input::tick();
278        self.midi.tick();
279        let new_events = Input::new_events();
280        if !new_events.is_empty() {
281            let log = Arc::make_mut(&mut self.input_log);
282            for event in new_events {
283                log.push(event);
284            }
285        }
286        self.handle_actions();
287
288        self.external_control_state
289            .process_events(&mut self.project, &self.collections);
290        self.evaluate_triggers();
291        if let Some(project) = &mut self.project {
292            project.update_input_state(self.timing.beat_progression());
293        }
294        self.apply_project_settings();
295
296        self.render_thread.send_snapshot(RenderSnapshot {
297            project: self.project.clone(),
298            blackout: self.blackout || self.blackout_hold,
299            output_config: self.output_config.clone(),
300            fps_limit: self.persistent_state.fps_limit(),
301        });
302
303        ProjectState {
304            project: self.project.clone(),
305            selected_scene_instance: self.selected_scene_instance,
306            blackout: self.blackout || self.blackout_hold,
307            beats_per_minute: self.timing.beats_per_minute(),
308            beat_progression: self.timing.beat_progression(),
309        }
310        .enqueue();
311
312        self.publish_state();
313        true
314    }
315
316    /// Applies what the user interfaces sent. Returns `false` once no
317    /// connector or connection is left.
318    fn receive_client_inputs(&mut self) -> bool {
319        loop {
320            match self.inputs.try_recv() {
321                Ok(Some(session::EngineInput::Connect { id, state, events })) => {
322                    debug!(id, "client connected");
323                    self.sessions.insert(
324                        id,
325                        session::Session {
326                            state,
327                            events,
328                            applied_sequence: 0,
329                            keys: BTreeSet::new(),
330                        },
331                    );
332                }
333                Ok(Some(session::EngineInput::Client { id, input })) => {
334                    self.apply_client_input(id, input)
335                }
336                Ok(Some(session::EngineInput::Disconnect(id))) => self.disconnect(id),
337                Ok(None) => return true,
338                Err(_) => return false,
339            }
340        }
341    }
342
343    fn apply_client_input(&mut self, id: ClientId, input: ClientInput) {
344        match input {
345            ClientInput::Action { sequence, action } => {
346                if let Some(session) = self.sessions.get_mut(&id) {
347                    session.applied_sequence = session.applied_sequence.max(sequence);
348                }
349                self.apply_action(action);
350            }
351            ClientInput::Keys(keys) => {
352                debug!(id, ?keys, "keys held in the interface");
353                if let Some(session) = self.sessions.get_mut(&id) {
354                    session.keys = keys;
355                }
356                self.update_keys();
357            }
358            ClientInput::SetBeatsPerMinute(bpm) => self.timing.set_beats_per_minute(bpm),
359            ClientInput::SaveAsset(asset) => asset.accept(&mut SaveInto(&mut self.collections)),
360            ClientInput::DeleteAsset(id) => id.accept(&mut DeleteFrom(&mut self.collections)),
361            ClientInput::Storage(command) => match command {
362                StorageCommand::Commit { message } => StorageAction::Commit { message }.enqueue(),
363                StorageCommand::Pull => StorageAction::Pull.enqueue(),
364                StorageCommand::Push => StorageAction::Push.enqueue(),
365                StorageCommand::SwitchBranch(branch) => {
366                    StorageAction::SwitchBranch(branch).enqueue()
367                }
368                StorageCommand::Reclone => StorageAction::Nuke.enqueue(),
369                StorageCommand::SetGitPassphrase(passphrase) => self
370                    .persistent_state
371                    .git_credentials_mut()
372                    .set_passphrase(passphrase),
373            },
374            ClientInput::SetServerSettings(settings) => self.apply_server_settings(settings),
375            ClientInput::WatchDevices(watch) => self.device_scan.watch(id, watch),
376            ClientInput::WatchMidi(watch) => self.midi.watch(id, watch),
377            ClientInput::MidiLearning(learning) => self.midi.learning(id, learning),
378            ClientInput::MidiTest(command) => self.midi.test(command),
379            ClientInput::UseSvgTemplate(name) => match svg_templates::read(&name) {
380                Ok(contents) => self.apply_action(UiAction::SetSvg(Some(
381                    gled_model::asset::svg::Svg::new(contents),
382                ))),
383                Err(err) => self.send_event(EngineEvent::Error(err)),
384            },
385            ClientInput::WatchSoundTriggers(triggers) => {
386                self.watched_triggers.insert(id, triggers);
387            }
388            ClientInput::HoverOutputRouting(routing) => {
389                let owned = self
390                    .hovered_routing
391                    .as_ref()
392                    .is_some_and(|(owner, _)| *owner == id);
393                match routing {
394                    Some(routing) => self.set_hovered_routing(Some((id, routing))),
395                    None if owned => self.set_hovered_routing(None),
396                    None => {}
397                }
398            }
399        }
400    }
401
402    fn disconnect(&mut self, id: ClientId) {
403        if self.sessions.remove(&id).is_some() {
404            debug!(id, "client disconnected");
405            // Keys it held are released, and a routing it pointed at.
406            self.update_keys();
407            self.device_scan.disconnect(id);
408            self.midi.disconnect(id);
409            self.watched_triggers.remove(&id);
410            if self
411                .hovered_routing
412                .as_ref()
413                .is_some_and(|(owner, _)| *owner == id)
414            {
415                self.set_hovered_routing(None);
416            }
417        }
418    }
419
420    fn set_hovered_routing(&mut self, hovered: Option<(ClientId, OutputRouting)>) {
421        *HOVERED_OUTPUT_ROUTING.lock() = hovered.as_ref().map(|(_, routing)| routing.clone());
422        self.hovered_routing = hovered;
423    }
424
425    /// A key counts as held while any interface holds it.
426    fn update_keys(&self) {
427        let keys = self
428            .sessions
429            .values()
430            .flat_map(|session| session.keys.iter().copied())
431            .collect();
432        Input::set_keys(keys);
433    }
434
435    /// The project's own trigger inputs: tap, blackout and tempo.
436    fn evaluate_triggers(&mut self) {
437        let Some(project) = &self.project else {
438            self.blackout_hold = false;
439            return;
440        };
441        if project.blackout_input_is_new() {
442            self.blackout = !self.blackout;
443        }
444        self.blackout_hold = project.blackout_hold_input_is_live();
445        if project.tap_input_is_new() {
446            self.timing.tap(&self.persistent_state);
447        }
448        if project.double_input_is_new() {
449            self.timing.multiply_speed(2.0, &self.persistent_state);
450        }
451        if project.half_input_is_new() {
452            self.timing.multiply_speed(0.5, &self.persistent_state);
453        }
454    }
455
456    /// Brings everything that follows project settings in line with the
457    /// project, whichever way it changed: the SVG's lamp layout and OSC.
458    fn apply_project_settings(&mut self) {
459        let svg = self
460            .project
461            .as_ref()
462            .and_then(|project| project.svg.as_ref());
463        if self.svg_layout.update(svg, &mut self.output_config) {
464            self.render_thread
465                .send_command(crate::runtime::render_thread::RenderCommand::SendPositions);
466        }
467
468        if let Some(project) = &self.project {
469            self.applied_output.apply(project);
470        }
471
472        let osc_config = self.project.as_ref().map(|project| project.osc_config);
473        if osc_config != self.osc_config {
474            self.osc_config = osc_config;
475            self.apply_osc_config(osc_config.unwrap_or_default());
476        }
477    }
478
479    fn apply_osc_config(&mut self, config: OscConfig) {
480        if let Some(handler) = self.osc_handler.take() {
481            handler.stop();
482        }
483        if config.active {
484            self.osc_handler = OSCHandler::start(config.port)
485                .map_err(|err| {
486                    UiAction::Error(format!(
487                        "Could not start OSC on port {}: {err}",
488                        config.port
489                    ))
490                    .enqueue();
491                })
492                .ok();
493        }
494    }
495
496    /// Keeps the running sound triggers in step with the triggers the project
497    /// and the scene assets use. Cheap when nothing changed.
498    fn sync_sound_triggers(&mut self) {
499        let mut wanted = SoundTriggerSet::new();
500        self.project.collect_sound_triggers(&mut wanted);
501        for scene in Asset::<Scene>::all(&self.collections) {
502            scene.data.collect_sound_triggers(&mut wanted);
503        }
504        if self.sound_data.sync_triggers(&wanted) {
505            self.sound_data.save();
506        }
507    }
508
509    fn receive_render_feedback(&mut self) {
510        while let Ok(Some(stats)) = self.network_stats_receiver.try_recv() {
511            self.network_stats = stats;
512        }
513        while let Ok(Some(temperature)) = self.temperature_receiver.try_recv() {
514            self.temperature = temperature;
515        }
516        while let Ok(Some(output)) = self.preview_output.try_recv() {
517            self.preview = Some(output);
518        }
519        if self.project.is_none() {
520            self.preview = None;
521        }
522        while let Some(feedback) = self.render_thread.try_recv_feedback() {
523            match feedback {
524                RenderFeedback::FrameStats(stats) => {
525                    self.timing.set_framerate(stats.fps);
526                    self.render_stats = stats;
527                }
528            }
529        }
530    }
531
532    fn publish_state(&mut self) {
533        let shared = Arc::new(EngineState {
534            project: self.project.clone(),
535            project_id: self.project_id,
536            selected_scene_instance: self.selected_scene_instance,
537            blackout: self.blackout,
538            blackout_hold: self.blackout_hold,
539            midi_output_active: self.midi_output_active,
540            output_config: self.output_config.clone(),
541            beat: self.timing.beat_state(),
542            tap_count: self.timing.tap_count(),
543            layout_generation: self.svg_layout.generation(),
544            render: self.render_stats,
545            status: self.server_status(),
546        });
547        self.ticks_since_sound += 1;
548        if self.ticks_since_sound >= SOUND_EVERY_TICKS {
549            self.ticks_since_sound = 0;
550            let watched: BTreeSet<Uuid> =
551                self.watched_triggers.values().flatten().copied().collect();
552            self.sound = Arc::new(SoundSnapshot {
553                levels: self.sound_data.trigger_levels().collect(),
554                impulses: self
555                    .sound_data
556                    .triggers
557                    .iter()
558                    .map(|(id, trigger)| (*id, trigger.impulse))
559                    .collect(),
560                spectrum: SharedBytes(Arc::new(self.sound_data.spectrum_bytes().to_vec())),
561                trigger_spectra: watched
562                    .iter()
563                    .filter_map(|id| {
564                        let trigger = self.sound_data.triggers.get(id)?;
565                        let bytes = trigger
566                            .spectrum
567                            .iter()
568                            .flat_map(|value| value.to_le_bytes())
569                            .collect();
570                        Some((*id, SharedBytes(Arc::new(bytes))))
571                    })
572                    .collect(),
573            });
574        }
575        if self.published_assets.0 != self.collections.generation() {
576            self.published_assets = (
577                self.collections.generation(),
578                Arc::new(AssetCollections::clone(&self.collections)),
579            );
580        }
581        let mut gone = Vec::new();
582        for (id, session) in &self.sessions {
583            // A full channel means the client has not taken the last states
584            // yet; it only ever wants the newest, so this one is dropped.
585            let state = ClientState {
586                shared: shared.clone(),
587                applied_sequence: session.applied_sequence,
588                assets: self.published_assets.1.clone(),
589                preview: self.preview.clone(),
590                sound: self.sound.clone(),
591                input_log: self.input_log.clone(),
592                midi_log: self.midi.monitor_log.clone(),
593                learn_log: self.midi.learn_log.clone(),
594            };
595            if session.state.try_send(state).is_err() {
596                gone.push(*id);
597            }
598        }
599        for id in gone {
600            self.disconnect(id);
601        }
602    }
603
604    fn send_event(&self, event: EngineEvent) {
605        for session in self.sessions.values() {
606            // A closed channel is noticed by the next `publish_state`.
607            let _ = session.events.send(event.clone());
608        }
609    }
610}
611
612/// Saves an asset a client sent, of whatever kind.
613struct SaveInto<'a>(&'a mut Collections);
614
615impl AssetVisitor for SaveInto<'_> {
616    fn asset<T: AssetTrait>(&mut self, asset: Arc<Asset<T>>) {
617        Arc::unwrap_or_clone(asset).save(self.0);
618    }
619}
620
621/// Deletes an asset a client named, of whatever kind.
622struct DeleteFrom<'a>(&'a mut Collections);
623
624impl AssetIdVisitor for DeleteFrom<'_> {
625    fn id<T: AssetTrait>(&mut self, id: AssetId<T>) {
626        delete_asset(id, self.0);
627    }
628}