Skip to main content

open_stomp/
device.rs

1//! Background engine (feature `device`): owns the MIDI connection (or a capture replay), finds
2//! and connects to the pedal, performs the startup sync, sends edits and reports every message.
3//!
4//! With feature `ble` the engine also finds the pedal over Bluetooth LE MIDI (the pedal's
5//! Wireless setting). A Bluetooth pedal is listed as a port named `"<name> (Bluetooth)"` and
6//! behaves like a USB one; auto-connect prefers USB when both are there.
7//!
8//! [`Device::spawn`] starts the engine on its own thread and returns a [`Device`] handle. Send it
9//! [`Command`]s (or use the helper methods) and read [`Update`]s from it. The engine only moves
10//! bytes; apply [`Update::Message`]s to a [`DeviceState`](crate::DeviceState) to follow the
11//! pedal's state, and reset that state on [`Update::Reset`].
12//!
13//! SysEx requests and writes go through a [`RequestQueue`] (one in flight, 1 s reply timeout,
14//! docs/03). Live CCs/PCs ([`Device::send`]) bypass it. The pedal never echoes host messages, so
15//! every message the engine sends is reported as an [`Update::Message`] with
16//! [`Origin::Host`]; applying those keeps a `DeviceState` in step with your own edits.
17//!
18//! Dropping the [`Device`] disconnects and stops the engine thread.
19
20use std::path::PathBuf;
21use std::sync::Arc;
22use std::sync::atomic::{AtomicBool, Ordering};
23use std::sync::mpsc::{self, Receiver, RecvError, RecvTimeoutError, Sender, TryRecvError};
24use std::thread::{self, JoinHandle};
25use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
26
27use midir::{Ignore, MidiInput, MidiInputConnection, MidiOutput, MidiOutputConnection};
28
29#[cfg(feature = "ble")]
30use crate::ble::{self, Ble};
31use crate::capture::{self, Entry};
32use crate::catalog::Module;
33use crate::midi::{Msg, hex, host, req};
34use crate::queue::{RequestQueue, follow_up, handshake};
35use crate::records::{Preset, Settings};
36use crate::upload::{self, UploadError, UserAmpInfo};
37use crate::{Assembler, Origin, is_ble_port, is_stomp_port};
38
39/// Engine settings.
40#[derive(Clone, Debug)]
41pub struct Config {
42    /// MIDI client name shown by the OS (ALSA sequencer, CoreMIDI).
43    pub client_name: String,
44    /// Connect to the first pedal-looking port found, and again whenever it is plugged back in.
45    /// [`Command::Disconnect`] turns this off, [`Command::Connect`] turns it back on.
46    pub auto_connect: bool,
47    /// Whether the engine may send anything (sync reads and edits). `false` = listen only.
48    pub sync: bool,
49    /// How long to wait for a SysEx reply or ack before moving on.
50    pub reply_timeout: Duration,
51    /// How often to rescan the MIDI ports (hotplug).
52    pub scan_interval: Duration,
53    /// Also look for the pedal over Bluetooth LE MIDI. Needs feature `ble`; ignored without it.
54    pub bluetooth: bool,
55}
56
57impl Default for Config {
58    fn default() -> Self {
59        Config {
60            client_name: "Open Stomp".into(),
61            auto_connect: true,
62            sync: true,
63            reply_timeout: Duration::from_secs(1),
64            scan_interval: Duration::from_millis(1500),
65            bluetooth: true,
66        }
67    }
68}
69
70pub enum Command {
71    /// Connect to a MIDI input port by name (and the matching output).
72    Connect(String),
73    Disconnect,
74    /// Read everything again (sends [`Update::Reset`] first).
75    Resync,
76    /// Whether the engine may send anything (sync reads and edits). Off = pure listening.
77    SetSync(bool),
78    /// Send a live message (CC/PC) right away.
79    Send(Vec<u8>),
80    /// Queue messages to send in order; SysEx writes wait for the pedal's ack.
81    Write(Vec<Vec<u8>>),
82    /// Play a capture log (docs/07) back instead of a pedal, at `speed`× its original timing.
83    Replay { path: PathBuf, speed: f32 },
84    StopReplay,
85}
86
87#[derive(Clone, Debug, PartialEq)]
88pub enum Status {
89    Disconnected,
90    Connected {
91        port: String,
92        /// An output to the pedal was opened; without one the engine only listens.
93        output: bool,
94    },
95    Replaying {
96        file: String,
97        progress: f32,
98    },
99}
100
101#[derive(Clone, Debug, PartialEq)]
102pub enum Update {
103    /// The MIDI input ports changed (sent on start and on every hotplug).
104    Ports(Vec<String>),
105    Status(Status),
106    /// Startup sync progress; `(0, 0)` when no sync is running.
107    Sync { done: usize, total: usize },
108    /// One complete MIDI message, either direction.
109    Message { at: SystemTime, origin: Origin, bytes: Vec<u8> },
110    /// A `# MARK` annotation from a replayed capture.
111    Mark { at: SystemTime, text: String },
112    /// A note for the user: connection changes, timeouts, refused sends.
113    Info { at: SystemTime, text: String, error: bool },
114    /// A new session starts (connect, resync or replay): reset your device model.
115    Reset,
116}
117
118enum Internal {
119    Cmd(Command),
120    Input(Vec<u8>),
121    #[cfg(feature = "ble")]
122    Ble(ble::Event),
123    Shutdown,
124}
125
126type Waker = Arc<dyn Fn() + Send + Sync>;
127
128/// Handle to the engine thread. Cheap methods; nothing blocks except the `recv*` calls.
129pub struct Device {
130    tx: Sender<Internal>,
131    rx: Receiver<Update>,
132    thread: Option<JoinHandle<()>>,
133}
134
135impl Device {
136    /// Start the engine with no wake-up callback (poll or block on [`recv`](Device::recv)).
137    pub fn spawn(config: Config) -> Device {
138        Device::spawn_with_waker(config, || {})
139    }
140
141    /// Start the engine; `wake` runs after every batch of updates (e.g. to repaint a GUI).
142    pub fn spawn_with_waker(config: Config, wake: impl Fn() + Send + Sync + 'static) -> Device {
143        let (tx, rx_internal) = mpsc::channel();
144        let (tx_updates, rx) = mpsc::channel();
145        let engine_tx = tx.clone();
146        let wake: Waker = Arc::new(wake);
147        let thread = thread::Builder::new()
148            .name("open-stomp-engine".into())
149            .spawn(move || Engine::new(config, tx_updates, engine_tx, wake).run(rx_internal))
150            .expect("spawn engine thread");
151        Device { tx, rx, thread: Some(thread) }
152    }
153
154    pub fn command(&self, c: Command) {
155        let _ = self.tx.send(Internal::Cmd(c));
156    }
157
158    pub fn try_recv(&self) -> Result<Update, TryRecvError> {
159        self.rx.try_recv()
160    }
161
162    pub fn recv(&self) -> Result<Update, RecvError> {
163        self.rx.recv()
164    }
165
166    pub fn recv_timeout(&self, timeout: Duration) -> Result<Update, RecvTimeoutError> {
167        self.rx.recv_timeout(timeout)
168    }
169
170    /// Every update available right now, without blocking.
171    pub fn drain(&self) -> impl Iterator<Item = Update> + '_ {
172        self.rx.try_iter()
173    }
174
175    pub fn connect(&self, port: impl Into<String>) {
176        self.command(Command::Connect(port.into()));
177    }
178
179    pub fn disconnect(&self) {
180        self.command(Command::Disconnect);
181    }
182
183    pub fn resync(&self) {
184        self.command(Command::Resync);
185    }
186
187    pub fn set_sync(&self, on: bool) {
188        self.command(Command::SetSync(on));
189    }
190
191    pub fn replay(&self, path: impl Into<PathBuf>, speed: f32) {
192        self.command(Command::Replay { path: path.into(), speed });
193    }
194
195    pub fn stop_replay(&self) {
196        self.command(Command::StopReplay);
197    }
198
199    /// Send a live message (CC/PC) now.
200    pub fn send(&self, bytes: Vec<u8>) {
201        self.command(Command::Send(bytes));
202    }
203
204    /// Queue messages behind any pending SysEx (writes wait for their ack).
205    pub fn write(&self, msgs: Vec<Vec<u8>>) {
206        self.command(Command::Write(msgs));
207    }
208
209    /// Select preset `index` (0-based, P01 = 0).
210    pub fn select_preset(&self, index: u8) {
211        self.send(host::program(index));
212    }
213
214    /// Set a module's model and on/off. Ignored for LOOP/DRUM (no head CC).
215    pub fn set_head(&self, m: Module, model: u8, on: bool) {
216        if let Some(b) = host::head(m, model, on) {
217            self.send(b);
218        }
219    }
220
221    /// Set a module parameter by slot (app display order). Ignored for slots the module lacks.
222    pub fn set_param(&self, m: Module, slot: usize, value: u8) {
223        if let Some(b) = host::param(m, slot, value) {
224            self.send(b);
225        }
226    }
227
228    pub fn set_volume(&self, value: u8) {
229        self.send(host::volume(value));
230    }
231
232    /// Make the pedal display jump to a module's page.
233    pub fn show_page(&self, m: Module) {
234        self.send(host::page(m));
235    }
236
237    /// Press a looper button (`host::LOOP_*`).
238    pub fn looper_button(&self, button: u8) {
239        self.send(host::looper_button(button));
240    }
241
242    /// Store `preset` in slot `preset.index` (with its name) and select it, using the official
243    /// app's sequence. `source_slot` is the active preset the edits were made on.
244    pub fn save_preset(&self, source_slot: u8, preset: &Preset) {
245        self.write(host::save_preset(source_slot, preset));
246    }
247
248    /// Import converted NAM data into user AMP `slot` (0–9) the way the official app does: select
249    /// the slot's amp model, write the slot-info record (name, factor), then the 2048 floats.
250    /// Converting a `.nam` file into those floats is not implemented yet (docs/05).
251    pub fn import_user_amp(&self, slot: u8, name: &str, data: &[f32], factor: f32) -> Result<(), UploadError> {
252        upload::check(slot, data)?;
253        if let Some(b) = host::head(Module::Amp, upload::USER_MODEL_BASE + slot, true) {
254            self.send(b);
255        }
256        self.write(upload::user_amp_upload(&UserAmpInfo::new(slot, name, factor), data));
257        Ok(())
258    }
259
260    /// Convert a `.nam` file with the best conversion, [`Measure::Optimised`](crate::nam::convert::Measure::Optimised)
261    /// (closer to the model on hardware than the official app's) at a normalised level
262    /// ([`crate::nam::convert::normalise_level`]), and import it into user AMP
263    /// `slot` (0–9), named after the file like the official app does. Never reads the official app
264    /// (see [`crate::nam::convert::official_excitation`] to opt in). The conversion runs on the
265    /// calling thread and takes about a minute (optimised build); [`Device::import_nam_file_with`]
266    /// with `Measure::App` gives the official algorithm in a few seconds. Returns the conversion.
267    #[cfg(feature = "nam")]
268    pub fn import_nam_file(
269        &self,
270        slot: u8,
271        path: impl AsRef<std::path::Path>,
272    ) -> Result<crate::nam::Converted, Box<dyn std::error::Error + Send + Sync>> {
273        self.import_nam_file_as(slot, path, crate::nam::convert::Measure::Optimised, true)
274    }
275
276    /// [`Device::import_nam_file`] with a choice of conversion
277    /// ([`Measure`](crate::nam::convert::Measure)): `Optimised` (the default, about a minute),
278    /// `App` (the official algorithm, a few seconds) or `Clean`. Keeps the model's own level, as
279    /// the official app does; [`Device::import_nam_file_as`] can normalise it.
280    #[cfg(feature = "nam")]
281    pub fn import_nam_file_with(
282        &self,
283        slot: u8,
284        path: impl AsRef<std::path::Path>,
285        measure: crate::nam::convert::Measure,
286    ) -> Result<crate::nam::Converted, Box<dyn std::error::Error + Send + Sync>> {
287        self.import_nam_file_as(slot, path, measure, false)
288    }
289
290    /// [`Device::import_nam_file_with`], optionally normalising the output level
291    /// ([`crate::nam::convert::normalise_level`]: every model plays at the same level, like NAM
292    /// plugins do; a pure volume change).
293    #[cfg(feature = "nam")]
294    pub fn import_nam_file_as(
295        &self,
296        slot: u8,
297        path: impl AsRef<std::path::Path>,
298        measure: crate::nam::convert::Measure,
299        normalise: bool,
300    ) -> Result<crate::nam::Converted, Box<dyn std::error::Error + Send + Sync>> {
301        let path = path.as_ref();
302        if slot >= upload::USER_SLOTS {
303            return Err(Box::new(UploadError::Slot(slot)));
304        }
305        let mut model = crate::nam::Model::load(path)?;
306        let mut converted =
307            crate::nam::convert::convert_measured(&mut model, &crate::nam::convert::excitation(), measure);
308        if normalise {
309            crate::nam::convert::normalise_level(&mut converted, &mut model);
310        }
311        let name = path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
312        self.import_user_amp(slot, &name, &converted.data, converted.factor)?;
313        Ok(converted)
314    }
315
316    /// Delete user AMP `slot` and re-read its info, like the official app.
317    pub fn delete_user_amp(&self, slot: u8) -> Result<(), UploadError> {
318        if slot >= upload::USER_SLOTS {
319            return Err(UploadError::Slot(slot));
320        }
321        self.write(vec![upload::user_amp_delete(slot), req::user_amp(slot)]);
322        Ok(())
323    }
324
325    /// Rename user AMP `slot` (0–9) without touching its sound: write only the slot-info record
326    /// (the import's first step, factor 1.0 like every import) with the new name, then re-read it.
327    /// The official app has no rename; this was confirmed on the pedal (the slot's response is
328    /// unchanged within 0.1 dB, docs/05). Names are cut to 31 characters.
329    pub fn rename_user_amp(&self, slot: u8, name: &str) -> Result<(), UploadError> {
330        if slot >= upload::USER_SLOTS {
331            return Err(UploadError::Slot(slot));
332        }
333        self.write(vec![UserAmpInfo::new(slot, name, 1.0).write_frame(), req::user_amp(slot)]);
334        Ok(())
335    }
336
337    /// Import IR data into user IR `slot` (0–9) the way the official app does: show the CAB page,
338    /// select the slot's cab model, write the 2048 floats, then the name. Only slot 0 has been
339    /// captured; other slots are assumed to work the same way.
340    pub fn import_user_ir(&self, slot: u8, name: &str, data: &[f32]) -> Result<(), UploadError> {
341        upload::check(slot, data)?;
342        self.send(host::page(Module::Cab));
343        if let Some(b) = host::head(Module::Cab, upload::USER_MODEL_BASE + slot, true) {
344            self.send(b);
345        }
346        self.write(upload::user_ir_upload(slot, name, data));
347        Ok(())
348    }
349
350    /// Rename user IR `slot` (0–9) without touching its sound: write only the `0x09` name record
351    /// (the import's last step), then re-read it. Confirmed on the pedal like
352    /// [`Device::rename_user_amp`]. Names are cut to 31 characters; the official app's imports use
353    /// at most 16 ([`crate::ir::slot_name`]).
354    pub fn rename_user_ir(&self, slot: u8, name: &str) -> Result<(), UploadError> {
355        if slot >= upload::USER_SLOTS {
356            return Err(UploadError::Slot(slot));
357        }
358        self.write(vec![upload::user_ir_name(slot, name), req::ir_slot(slot)]);
359        Ok(())
360    }
361
362    /// Convert an IR audio file (WAV, any sample rate) with the best conversion,
363    /// [`crate::ir::convert_optimised`] (a 384-tap filter fitted to the whole IR: the pedal only
364    /// plays 384 taps, so this is much closer to the real IR than the official app's), and import
365    /// it into user IR `slot` (0–9), named after the file like the official app does (first 16
366    /// characters). [`Device::import_ir_file_with`] with `Method::App` gives exactly the official
367    /// conversion. Returns the conversion.
368    pub fn import_ir_file(
369        &self,
370        slot: u8,
371        path: impl AsRef<std::path::Path>,
372    ) -> Result<crate::ir::ConvertedIr, Box<dyn std::error::Error + Send + Sync>> {
373        #[cfg(feature = "nam")]
374        let method = crate::ir::Method::Optimised;
375        #[cfg(not(feature = "nam"))]
376        let method = crate::ir::Method::App;
377        self.import_ir_file_with(slot, path, method)
378    }
379
380    /// [`Device::import_ir_file`] with a choice of conversion ([`crate::ir::Method`]).
381    pub fn import_ir_file_with(
382        &self,
383        slot: u8,
384        path: impl AsRef<std::path::Path>,
385        method: crate::ir::Method,
386    ) -> Result<crate::ir::ConvertedIr, Box<dyn std::error::Error + Send + Sync>> {
387        let path = path.as_ref();
388        if slot >= upload::USER_SLOTS {
389            return Err(Box::new(UploadError::Slot(slot)));
390        }
391        let converted = crate::ir::convert_with(&crate::ir::Wav::load(path)?, method);
392        self.import_user_ir(slot, &crate::ir::slot_name(path), &converted.data)?;
393        Ok(converted)
394    }
395
396    /// Write the global settings (read-modify-write: start from the pedal's current record).
397    pub fn write_settings(&self, settings: &Settings) {
398        self.write(vec![host::settings_write(settings)]);
399    }
400}
401
402impl Drop for Device {
403    fn drop(&mut self) {
404        let _ = self.tx.send(Internal::Shutdown);
405        if let Some(t) = self.thread.take() {
406            let _ = t.join();
407        }
408    }
409}
410
411struct Connection {
412    port: String,
413    link: Link,
414}
415
416enum Link {
417    Midi {
418        _input: MidiInputConnection<()>,
419        output: Option<MidiOutputConnection>,
420    },
421    /// The [`Ble`] link thread owns the connection.
422    #[cfg(feature = "ble")]
423    Ble,
424}
425
426struct Engine {
427    config: Config,
428    out: Sender<Update>,
429    self_tx: Sender<Internal>,
430    wake: Waker,
431    scanner: Option<MidiInput>,
432    ports: Vec<String>,
433    last_scan: Option<Instant>,
434    conn: Option<Connection>,
435    replay: Option<Arc<AtomicBool>>,
436    asm: Assembler,
437    queue: RequestQueue,
438    #[cfg(feature = "ble")]
439    ble: Option<Ble>,
440    /// Pedals in Bluetooth range, as port names.
441    #[cfg(feature = "ble")]
442    ble_ports: Vec<String>,
443}
444
445impl Engine {
446    fn new(config: Config, out: Sender<Update>, self_tx: Sender<Internal>, wake: Waker) -> Self {
447        let scanner = MidiInput::new(&format!("{} scan", config.client_name)).ok();
448        let queue = RequestQueue::new(config.reply_timeout);
449        #[cfg(feature = "ble")]
450        let ble = config.bluetooth.then(|| Ble::spawn(ble::forward(self_tx.clone(), Internal::Ble)));
451        Engine {
452            config,
453            out,
454            self_tx,
455            wake,
456            scanner,
457            ports: vec![],
458            last_scan: None,
459            conn: None,
460            replay: None,
461            asm: Assembler::default(),
462            queue,
463            #[cfg(feature = "ble")]
464            ble,
465            #[cfg(feature = "ble")]
466            ble_ports: vec![],
467        }
468    }
469
470    fn emit(&self, u: Update) {
471        let _ = self.out.send(u);
472        (self.wake)();
473    }
474
475    fn info(&self, text: impl Into<String>, error: bool) {
476        self.emit(Update::Info { at: SystemTime::now(), text: text.into(), error });
477    }
478
479    fn emit_progress(&self) {
480        let (done, total) = self.queue.progress();
481        self.emit(Update::Sync { done, total });
482    }
483
484    fn run(mut self, rx: Receiver<Internal>) {
485        if self.scanner.is_none() {
486            self.info("MIDI is unavailable on this system (could not create a MIDI client)", true);
487        }
488        loop {
489            match rx.recv_timeout(Duration::from_millis(50)) {
490                Ok(Internal::Cmd(c)) => self.command(c),
491                Ok(Internal::Input(data)) => self.input(&data),
492                #[cfg(feature = "ble")]
493                Ok(Internal::Ble(e)) => self.ble_event(e),
494                Ok(Internal::Shutdown) | Err(RecvTimeoutError::Disconnected) => break,
495                Err(RecvTimeoutError::Timeout) => {}
496            }
497            self.pump();
498            #[cfg(target_os = "macos")]
499            run_loop_tick();
500            if self.last_scan.is_none_or(|t| t.elapsed() > self.config.scan_interval) {
501                self.scan();
502            }
503        }
504        self.stop_replay();
505        self.conn = None;
506    }
507
508    fn command(&mut self, c: Command) {
509        match c {
510            Command::Connect(port) => {
511                self.stop_replay();
512                self.config.auto_connect = true;
513                self.connect(&port);
514            }
515            Command::Disconnect => {
516                self.config.auto_connect = false;
517                self.disconnect("Disconnected");
518            }
519            Command::Resync => {
520                if self.conn.is_some() {
521                    self.emit(Update::Reset);
522                    self.start_sync();
523                }
524            }
525            Command::SetSync(on) => {
526                self.config.sync = on;
527                if !on {
528                    self.queue.clear();
529                    self.emit_progress();
530                }
531            }
532            Command::Send(bytes) => {
533                if self.writable() {
534                    self.transmit(&bytes);
535                }
536            }
537            Command::Write(msgs) => {
538                if self.writable() {
539                    for bytes in msgs {
540                        self.queue.write(bytes);
541                    }
542                }
543            }
544            Command::Replay { path, speed } => {
545                self.config.auto_connect = false;
546                self.disconnect("Disconnected for replay");
547                self.start_replay(path, speed);
548            }
549            Command::StopReplay => {
550                if self.stop_replay() {
551                    self.emit(Update::Status(Status::Disconnected));
552                }
553            }
554        }
555    }
556
557    fn scan(&mut self) {
558        self.last_scan = Some(Instant::now());
559        let mut ports: Vec<String> = match &self.scanner {
560            Some(s) => s.ports().iter().filter_map(|p| s.port_name(p).ok()).collect(),
561            None => vec![],
562        };
563        #[cfg(feature = "ble")]
564        ports.extend(self.ble_ports.iter().cloned());
565        if ports != self.ports {
566            self.ports = ports.clone();
567            self.emit(Update::Ports(ports.clone()));
568        }
569        if let Some(c) = &self.conn
570            && !ports.contains(&c.port)
571        {
572            self.disconnect("Pedal unplugged");
573        }
574        if self.conn.is_none()
575            && self.replay.is_none()
576            && self.config.auto_connect
577            && let Some(p) = ports
578                .iter()
579                .find(|p| is_stomp_port(p) && !is_ble_port(p))
580                .or_else(|| ports.iter().find(|p| is_stomp_port(p)))
581        {
582            self.connect(&p.clone());
583        }
584        // the pedal was plugged in while connected over Bluetooth: move to USB
585        if self.config.auto_connect
586            && self.conn.as_ref().is_some_and(|c| is_ble_port(&c.port))
587            && let Some(p) = ports.iter().find(|p| is_stomp_port(p) && !is_ble_port(p))
588        {
589            self.connect(&p.clone());
590        }
591    }
592
593    #[cfg(feature = "ble")]
594    fn ble_event(&mut self, e: ble::Event) {
595        match e {
596            ble::Event::Ports(p) => {
597                self.ble_ports = p;
598                self.scan();
599            }
600            ble::Event::Input(bytes) => {
601                if self.conn.as_ref().is_some_and(|c| matches!(c.link, Link::Ble)) {
602                    self.input(&bytes);
603                }
604            }
605            ble::Event::Lost => {
606                if self.conn.as_ref().is_some_and(|c| matches!(c.link, Link::Ble)) {
607                    self.disconnect("Bluetooth connection lost");
608                }
609            }
610            ble::Event::Unavailable(why) => {
611                self.ble = None;
612                self.info(format!("Bluetooth is unavailable: {why}"), false);
613            }
614        }
615    }
616
617    fn connect(&mut self, port_name: &str) {
618        self.disconnect_quiet();
619        #[cfg(feature = "ble")]
620        if is_ble_port(port_name)
621            && let Some(ble) = &self.ble
622        {
623            self.info(format!("Connecting to {port_name} …"), false);
624            match ble.connect(port_name) {
625                Ok(()) => self.connected(Connection { port: port_name.to_owned(), link: Link::Ble }),
626                Err(e) => self.info(format!("Could not connect: {e}"), true),
627            }
628            return;
629        }
630        let result = (|| -> Result<Connection, String> {
631            let mut input = MidiInput::new(&self.config.client_name).map_err(|e| e.to_string())?;
632            input.ignore(Ignore::None);
633            let port = input
634                .ports()
635                .into_iter()
636                .find(|p| input.port_name(p).as_deref() == Ok(port_name))
637                .ok_or_else(|| format!("port “{port_name}” not found"))?;
638            let tx = self.self_tx.clone();
639            let input = input
640                .connect(
641                    &port,
642                    "stomp-in",
643                    move |_ts, data, _| {
644                        let _ = tx.send(Internal::Input(data.to_vec()));
645                    },
646                    (),
647                )
648                .map_err(|e| e.to_string())?;
649            let output = open_output(&self.config.client_name, port_name);
650            Ok(Connection { port: port_name.to_owned(), link: Link::Midi { _input: input, output } })
651        })();
652        match result {
653            Ok(c) => self.connected(c),
654            Err(e) => self.info(format!("Could not connect: {e}"), true),
655        }
656    }
657
658    fn connected(&mut self, c: Connection) {
659        let port = c.port.clone();
660        self.conn = Some(c);
661        let output = self.has_output();
662        self.emit(Update::Reset);
663        self.emit(Update::Status(Status::Connected { port: port.clone(), output }));
664        self.info(format!("Connected to {port}"), false);
665        if !output {
666            self.info("No MIDI output to the pedal: listening only", true);
667        }
668        self.start_sync();
669    }
670
671    fn disconnect_quiet(&mut self) {
672        #[cfg(feature = "ble")]
673        if let Some(Connection { link: Link::Ble, .. }) = self.conn
674            && let Some(ble) = &self.ble
675        {
676            ble.disconnect();
677        }
678        self.conn = None;
679        self.queue.clear();
680        self.asm = Assembler::default();
681    }
682
683    fn disconnect(&mut self, why: &str) {
684        if self.conn.is_some() {
685            self.disconnect_quiet();
686            self.emit(Update::Status(Status::Disconnected));
687            self.emit_progress();
688            self.info(why, false);
689        }
690    }
691
692    fn has_output(&self) -> bool {
693        self.conn.as_ref().is_some_and(|c| match &c.link {
694            Link::Midi { output, .. } => output.is_some(),
695            #[cfg(feature = "ble")]
696            Link::Ble => true,
697        })
698    }
699
700    fn start_sync(&mut self) {
701        self.queue.clear();
702        if !self.config.sync || !self.has_output() {
703            return;
704        }
705        self.queue.start_sync(handshake());
706        self.emit_progress();
707    }
708
709    /// Edits are allowed when connected with an output and not in listen-only mode.
710    fn writable(&self) -> bool {
711        let ok = self.config.sync && self.has_output();
712        if !ok {
713            self.info("Not sent: listen-only mode, or no MIDI output to the pedal", true);
714        }
715        ok
716    }
717
718    /// Send one message now and report it. The pedal never echoes host messages.
719    fn transmit(&mut self, bytes: &[u8]) -> bool {
720        let sent = match self.conn.as_mut().map(|c| &mut c.link) {
721            Some(Link::Midi { output: Some(out), .. }) => out.send(bytes).map_err(|e| e.to_string()),
722            #[cfg(feature = "ble")]
723            Some(Link::Ble) => match &self.ble {
724                Some(ble) if ble.send(vec![bytes.to_vec()]) => Ok(()),
725                _ => Err("Bluetooth stopped".into()),
726            },
727            _ => return false,
728        };
729        if let Err(e) = sent {
730            self.info(format!("Send failed: {e}"), true);
731            return false;
732        }
733        self.emit(Update::Message { at: SystemTime::now(), origin: Origin::Host, bytes: bytes.to_vec() });
734        true
735    }
736
737    /// Send queued requests while nothing is in flight; expire a timed-out request.
738    fn pump(&mut self) {
739        if let Some(done) = self.queue.expire(Instant::now()) {
740            let b = done.request.bytes();
741            self.info(format!("No reply from pedal to {} …", hex(&b[..b.len().min(8)])), true);
742            if done.request.is_sync() {
743                self.emit_progress();
744            }
745        }
746        while let Some(r) = self.queue.pop() {
747            if !self.has_output() {
748                self.queue.clear();
749                return;
750            }
751            if !self.transmit(r.bytes()) {
752                return;
753            }
754            self.queue.sent(r, Instant::now());
755        }
756    }
757
758    fn input(&mut self, data: &[u8]) {
759        if self.conn.is_none() {
760            return;
761        }
762        let at = SystemTime::now();
763        for bytes in self.asm.push(data) {
764            let msg = Msg::parse(&bytes);
765            if let Some(done) = self.queue.on_message(&msg)
766                && done.request.is_sync()
767            {
768                self.emit_progress();
769            }
770            if self.config.sync {
771                for r in follow_up(&msg) {
772                    self.queue.read(r);
773                }
774            }
775            self.emit(Update::Message { at, origin: Origin::Pedal, bytes });
776        }
777    }
778
779    fn start_replay(&mut self, path: PathBuf, speed: f32) {
780        self.stop_replay();
781        let text = match std::fs::read_to_string(&path) {
782            Ok(t) => t,
783            Err(e) => {
784                self.info(format!("Could not read {}: {e}", path.display()), true);
785                return;
786            }
787        };
788        let entries = capture::parse(&text);
789        let file = path.file_name().map(|f| f.to_string_lossy().into_owned()).unwrap_or_default();
790        let stop = Arc::new(AtomicBool::new(false));
791        self.replay = Some(stop.clone());
792        self.emit(Update::Reset);
793        self.emit(Update::Status(Status::Replaying { file: file.clone(), progress: 0.0 }));
794        self.info(format!("Replaying {file} ({} messages) at {speed}×", entries.len()), false);
795        let (out, wake) = (self.out.clone(), self.wake.clone());
796        thread::spawn(move || replay(entries, file, speed, stop, out, wake));
797    }
798
799    /// Returns whether a replay was running.
800    fn stop_replay(&mut self) -> bool {
801        match self.replay.take() {
802            Some(stop) => {
803                stop.store(true, Ordering::Relaxed);
804                true
805            }
806            None => false,
807        }
808    }
809}
810
811/// CoreMIDI updates a client's view of the devices (hotplug) through notifications delivered on
812/// the run loop of the thread that created the client. The engine thread has no run loop of its
813/// own, so give it one turn per tick without blocking.
814#[cfg(target_os = "macos")]
815fn run_loop_tick() {
816    use core_foundation_sys::runloop::{CFRunLoopRunInMode, kCFRunLoopDefaultMode};
817    // SAFETY: plain CoreFoundation call on the current thread; returns immediately (0 s timeout).
818    unsafe {
819        CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.0, 1);
820    }
821}
822
823fn open_output(client: &str, port_name: &str) -> Option<MidiOutputConnection> {
824    let out = MidiOutput::new(client).ok()?;
825    let ports = out.ports();
826    let names: Vec<String> = ports.iter().map(|p| out.port_name(p).unwrap_or_default()).collect();
827    // exact name first (ALSA, CoreMIDI), else any pedal-looking port (Windows names differ)
828    let i = names.iter().position(|n| n == port_name).or_else(|| names.iter().position(|n| is_stomp_port(n)))?;
829    out.connect(&ports[i], "stomp-out").ok()
830}
831
832/// Play a capture log back with its original timing (gaps capped at 1.5 s), scaled by `speed`.
833fn replay(entries: Vec<Entry>, file: String, speed: f32, stop: Arc<AtomicBool>, out: Sender<Update>, wake: Waker) {
834    let total = entries.len().max(1);
835    let mut prev_ms: Option<u64> = None;
836    for (i, e) in entries.into_iter().enumerate() {
837        if stop.load(Ordering::Relaxed) {
838            return;
839        }
840        let ms = match &e {
841            Entry::Mark { ms, .. } | Entry::Message { ms, .. } => *ms,
842        };
843        if let Some(p) = prev_ms {
844            let gap = ms.saturating_sub(p).min(1500) as f32 / speed.max(0.01);
845            if gap >= 1.0 {
846                thread::sleep(Duration::from_millis(gap as u64));
847            }
848        }
849        prev_ms = Some(ms);
850        let at = UNIX_EPOCH + Duration::from_millis(ms);
851        let u = match e {
852            Entry::Mark { text, .. } => Update::Mark { at, text },
853            Entry::Message { origin, bytes, .. } => Update::Message { at, origin, bytes },
854        };
855        if out.send(u).is_err() {
856            return;
857        }
858        if i % 25 == 0 {
859            let progress = i as f32 / total as f32;
860            let _ = out.send(Update::Status(Status::Replaying { file: file.clone(), progress }));
861        }
862        wake();
863    }
864    let _ = out.send(Update::Status(Status::Replaying { file, progress: 1.0 }));
865    let _ = out.send(Update::Info { at: SystemTime::now(), text: "Replay finished".into(), error: false });
866    wake();
867}