Skip to main content

open_stomp/
state.rs

1//! Device state tracker: applies MIDI traffic (either direction) to a model of the pedal and
2//! describes each message in plain English.
3
4use crate::catalog::{Module, NOTES, drum_pattern_name};
5use crate::midi::{Cmd, Dir, HOST_CH, Msg, hex};
6use crate::records::{PRESET_COUNT, Preset, Settings, parse_ir_slot, u14};
7
8#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
9pub enum Origin {
10    /// Pedal → host.
11    Pedal,
12    /// Host → pedal (this app, or the official app in a replayed capture).
13    Host,
14}
15
16#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
17pub enum Category {
18    Preset,
19    Effect(Module),
20    Volume,
21    Navigation,
22    Looper,
23    Drum,
24    Tuner,
25    Settings,
26    Device,
27    /// Request/reply plumbing (handshake reads, acks, upload chunks).
28    Protocol,
29    Unknown,
30}
31
32impl Category {
33    pub fn label(self) -> &'static str {
34        match self {
35            Category::Preset => "PRESET",
36            Category::Effect(m) => m.name(),
37            Category::Volume => "VOLUME",
38            Category::Navigation => "PAGE",
39            Category::Looper => "LOOPER",
40            Category::Drum => "DRUM",
41            Category::Tuner => "TUNER",
42            Category::Settings => "SETTINGS",
43            Category::Device => "DEVICE",
44            Category::Protocol => "SYSEX",
45            Category::Unknown => "???",
46        }
47    }
48}
49
50/// A human-readable description of one MIDI message.
51#[derive(Clone, Debug)]
52pub struct Event {
53    pub origin: Origin,
54    pub category: Category,
55    /// What changed, e.g. `"Mar J800 · GAIN"`.
56    pub subject: String,
57    /// The new value, e.g. `"64"`.
58    pub value: String,
59    /// Consecutive events with the same key may be merged into one log row (knob sweeps).
60    pub coalesce: Option<u64>,
61    /// The message didn't change the tracked state (e.g. the CC burst after a preset change
62    /// repeating values already known from the preset record, or values for unused param slots).
63    pub redundant: bool,
64    pub raw: Vec<u8>,
65}
66
67#[derive(Clone, Debug, Default)]
68pub struct Looper {
69    /// 0 empty, 1 recording, 2 playing, 3 overdubbing, 4 stopped.
70    pub state: u8,
71    /// 0x1 = overdub layer on top (undo available), 0x2 = base layer only.
72    pub layers: u8,
73    pub rec_level: Option<u8>,
74    pub play_level: Option<u8>,
75    pub sync: Option<bool>,
76    /// 0 NR, 1 AR.
77    pub mode: Option<u8>,
78}
79
80impl Looper {
81    pub fn set(&mut self, v: u8) {
82        self.state = v & 0x0F;
83        self.layers = v >> 4;
84    }
85
86    pub fn state_name(&self) -> &'static str {
87        looper_state_name(self.state)
88    }
89}
90
91pub fn looper_state_name(s: u8) -> &'static str {
92    match s {
93        0 => "Empty",
94        1 => "Recording",
95        2 => "Playing",
96        3 => "Overdubbing",
97        4 => "Stopped",
98        _ => "Unknown",
99    }
100}
101
102#[derive(Clone, Debug, Default)]
103pub struct Drum {
104    pub playing: bool,
105    pub pattern: Option<u8>,
106    pub level: Option<u8>,
107    pub bpm: Option<u16>,
108}
109
110#[derive(Clone, Debug, Default)]
111pub struct Tuner {
112    pub active: bool,
113    pub detected: bool,
114    pub note: Option<u8>,
115    /// Raw, 50 = in tune.
116    pub cents: Option<u8>,
117}
118
119impl Tuner {
120    pub fn cents_offset(&self) -> Option<i16> {
121        self.cents.map(|c| c as i16 - 50)
122    }
123}
124
125#[derive(Clone, Debug)]
126pub struct DeviceState {
127    pub firmware: Option<String>,
128    pub model_id: Option<String>,
129    pub product: Option<String>,
130    pub presets: Vec<Option<Preset>>,
131    /// Active preset index (0-based).
132    pub current: Option<u8>,
133    /// The live edit buffer: stored preset + every change since.
134    pub live: Preset,
135    pub live_known: bool,
136    pub page: Option<Module>,
137    pub looper: Looper,
138    pub drum: Drum,
139    pub tuner: Tuner,
140    pub settings: Option<Settings>,
141    pub user_amps: [Option<String>; 10],
142    pub user_irs: [Option<String>; 10],
143}
144
145impl Default for DeviceState {
146    fn default() -> Self {
147        DeviceState {
148            firmware: None,
149            model_id: None,
150            product: None,
151            presets: vec![None; PRESET_COUNT],
152            current: None,
153            live: Preset::default(),
154            live_known: false,
155            page: None,
156            looper: Looper::default(),
157            drum: Drum::default(),
158            tuner: Tuner::default(),
159            settings: None,
160            user_amps: Default::default(),
161            user_irs: Default::default(),
162        }
163    }
164}
165
166fn key(parts: &[u64]) -> Option<u64> {
167    Some(parts.iter().fold(0xcbf29ce484222325u64, |h, p| (h ^ p).wrapping_mul(0x100000001b3)))
168}
169
170/// `"4352 / 8192 bytes"` for an upload chunk.
171fn upload_progress(payload: &[u8]) -> String {
172    match crate::upload::Chunk::parse(payload) {
173        Some(c) => format!("{} / {} bytes", c.offset as usize + c.data.len(), c.total),
174        None => format!("{} bytes", payload.len()),
175    }
176}
177
178pub fn preset_label(i: u8) -> String {
179    format!("P{:02}", i as u16 + 1)
180}
181
182impl DeviceState {
183    pub fn stored(&self, i: u8) -> Option<&Preset> {
184        self.presets.get(i as usize)?.as_ref()
185    }
186
187    pub fn preset_name(&self, i: u8) -> Option<&str> {
188        self.stored(i).map(|p| p.name.as_str())
189    }
190
191    /// The live edit buffer differs from the stored preset.
192    pub fn modified(&self) -> bool {
193        match (self.current, self.live_known) {
194            (Some(i), true) => self.stored(i).is_some_and(|p| !p.same_sound(&self.live)),
195            _ => false,
196        }
197    }
198
199    /// Model name for display, using user slot names where known.
200    pub fn model_name(&self, m: Module, model: u8) -> String {
201        let user = match m {
202            Module::Amp if model >= 30 => self.user_amps.get(model as usize - 30),
203            Module::Cab if model >= 30 => self.user_irs.get(model as usize - 30),
204            _ => None,
205        };
206        match user.and_then(|n| n.as_deref()) {
207            Some(n) if !n.is_empty() && !n.starts_with("--") && n != m.model_name(model) => {
208                format!("{} · {n}", m.model_name(model))
209            }
210            _ => m.model_name(model),
211        }
212    }
213
214    /// Apply one complete MIDI message and describe it.
215    pub fn apply(&mut self, bytes: &[u8], origin: Origin) -> Event {
216        let mut ev = Event {
217            origin,
218            category: Category::Unknown,
219            subject: String::new(),
220            value: String::new(),
221            coalesce: None,
222            redundant: false,
223            raw: bytes.to_vec(),
224        };
225        match Msg::parse(bytes) {
226            Msg::Cc { ch, cc, val } => self.apply_cc(&mut ev, ch, cc, val),
227            Msg::Pc { prog, .. } => {
228                self.select_preset(prog);
229                ev.category = Category::Preset;
230                ev.subject = "Preset selected".into();
231                ev.value = match self.preset_name(prog) {
232                    Some(n) => format!("{} · {n}", preset_label(prog)),
233                    None => preset_label(prog),
234                };
235            }
236            Msg::Short { kind, payload } => self.apply_short(&mut ev, kind, &payload),
237            Msg::Notify { what, payload } => self.apply_notify(&mut ev, what, &payload),
238            Msg::Nep { cmd, dir, payload } => self.apply_nep(&mut ev, cmd, dir, &payload),
239            Msg::Other(b) => {
240                ev.subject = "Unrecognised message".into();
241                ev.value = hex(&b);
242            }
243        }
244        ev
245    }
246
247    pub fn select_preset(&mut self, prog: u8) {
248        self.current = Some(prog);
249        if let Some(p) = self.stored(prog) {
250            self.live = p.clone();
251            self.live_known = true;
252        }
253    }
254
255    fn apply_cc(&mut self, ev: &mut Event, ch: u8, cc: u8, val: u8) {
256        let o = ev.origin as u64;
257        match cc {
258            0x00..=0x07 => {
259                let m = Module::from_index(cc).unwrap();
260                let st = &mut self.live.modules[m.index()];
261                let (was_on, was_model) = (st.on, st.model);
262                ev.redundant = st.head() == val && self.live_known;
263                st.set_head(val);
264                let (on, model) = (st.on, st.model);
265                ev.category = Category::Effect(m);
266                let name = self.model_name(m, model);
267                if model != was_model {
268                    ev.subject = "Model".into();
269                    ev.value = format!("#{:02} {name}", model + 1);
270                    ev.coalesce = key(&[o, 1, cc as u64]);
271                } else {
272                    ev.subject = name;
273                    ev.value = if on { "ON" } else { "OFF" }.into();
274                }
275                if on != was_on && model != was_model {
276                    ev.value.push_str(if on { " · ON" } else { " · OFF" });
277                }
278            }
279            0x08..=0x39 if Module::from_param_cc(cc).is_some() => {
280                let (m, slot) = Module::from_param_cc(cc).unwrap();
281                let st = &mut self.live.modules[m.index()];
282                let model = st.model;
283                ev.redundant = (st.params[slot] == val && self.live_known) || m.param(model, slot).is_none();
284                st.params[slot] = val;
285                ev.category = Category::Effect(m);
286                let (pname, pval) = match m.param(model, slot) {
287                    Some(p) => (p.name.to_owned(), p.format(val)),
288                    None => (format!("P{}", slot + 1), val.to_string()),
289                };
290                ev.subject = format!("{} · {pname}", m.model_name(model));
291                ev.value = pval;
292                ev.coalesce = key(&[o, 2, cc as u64, model as u64]);
293            }
294            0x3A => {
295                ev.redundant = self.live.volume == val && self.live_known;
296                self.live.volume = val;
297                ev.category = Category::Volume;
298                ev.subject = "Preset volume".into();
299                ev.value = val.to_string();
300                ev.coalesce = key(&[o, 3]);
301            }
302            0x3B => {
303                self.page = Module::from_index(val);
304                ev.category = Category::Navigation;
305                ev.subject = "Pedal page".into();
306                ev.value = self.page.map(|m| m.name().to_owned()).unwrap_or_else(|| val.to_string());
307            }
308            0x3F => {
309                self.drum.playing = val != 0;
310                ev.category = Category::Drum;
311                ev.subject = "Drum".into();
312                ev.value = if val != 0 { "PLAYING" } else { "STOPPED" }.into();
313            }
314            0x40 => {
315                self.drum.pattern = Some(val);
316                ev.category = Category::Drum;
317                ev.subject = "Pattern".into();
318                ev.value = drum_pattern_name(val);
319                ev.coalesce = key(&[o, 4]);
320            }
321            0x41 => {
322                self.drum.level = Some(val);
323                ev.category = Category::Drum;
324                ev.subject = "Drum level".into();
325                ev.value = val.to_string();
326                ev.coalesce = key(&[o, 5]);
327            }
328            0x42 | 0x43 => {
329                let (slot, name) = if cc == 0x42 {
330                    (&mut self.looper.rec_level, "Record level")
331                } else {
332                    (&mut self.looper.play_level, "Playback level")
333                };
334                *slot = Some(val);
335                ev.category = Category::Looper;
336                ev.subject = name.into();
337                ev.value = val.to_string();
338                ev.coalesce = key(&[o, 6, cc as u64]);
339            }
340            0x44 if ch == HOST_CH => {
341                ev.category = Category::Looper;
342                ev.subject = "Looper button".into();
343                ev.value = match val {
344                    1 => "REC / PLAY / DUB",
345                    2 => "STOP",
346                    4 => "CLEAR",
347                    8 => "UNDO / REDO",
348                    _ => "?",
349                }
350                .into();
351            }
352            0x44 => {
353                self.looper.set(val);
354                ev.category = Category::Looper;
355                ev.subject = "Looper".into();
356                ev.value = self.looper.state_name().to_uppercase();
357                match self.looper.layers {
358                    1 => ev.value.push_str(" · overdub layer (undo available)"),
359                    2 => ev.value.push_str(" · base layer"),
360                    _ => {}
361                }
362            }
363            0x45 | 0x46 | 0x48 => {
364                match cc {
365                    0x45 => self.tuner.detected = val != 0,
366                    0x46 => self.tuner.note = Some(val % 12),
367                    _ => self.tuner.cents = Some(val),
368                }
369                ev.category = Category::Tuner;
370                ev.subject = "Pitch".into();
371                ev.value = match (self.tuner.detected, self.tuner.note, self.tuner.cents_offset()) {
372                    (false, _, _) => "—".into(),
373                    (true, Some(n), Some(c)) => format!("{} {c:+} ¢", NOTES[n as usize]),
374                    (true, Some(n), None) => NOTES[n as usize].into(),
375                    _ => "detecting…".into(),
376                };
377                ev.coalesce = key(&[o, 7]);
378            }
379            0x49 => {
380                self.looper.sync = Some(val != 0);
381                ev.category = Category::Looper;
382                ev.subject = "Loop sync".into();
383                ev.value = if val != 0 { "ON" } else { "OFF" }.into();
384            }
385            0x4A => {
386                self.looper.mode = Some(val);
387                ev.category = Category::Looper;
388                ev.subject = "Loop mode".into();
389                ev.value = if val == 0 { "NR" } else { "AR" }.into();
390            }
391            _ => {
392                ev.subject = format!("CC 0x{cc:02X} (ch {})", ch + 1);
393                ev.value = val.to_string();
394                ev.coalesce = key(&[o, 8, cc as u64]);
395            }
396        }
397    }
398
399    fn apply_short(&mut self, ev: &mut Event, kind: u8, payload: &[u8]) {
400        ev.category = Category::Device;
401        let strings: Vec<String> = payload
402            .split(|b| *b == 0)
403            .filter(|s| !s.is_empty() && s.iter().all(|c| (0x20..0x7F).contains(c)))
404            .map(|s| String::from_utf8_lossy(s).into_owned())
405            .collect();
406        match kind {
407            0x00 => {
408                ev.category = Category::Protocol;
409                ev.subject = "Version request".into();
410            }
411            0x10 if strings.is_empty() => {
412                ev.subject = "Identity ping".into();
413            }
414            0x10 => {
415                self.firmware = strings.first().cloned();
416                self.model_id = strings.get(1).cloned();
417                ev.subject = "Firmware".into();
418                ev.value = strings.join(" · ");
419            }
420            0x60 if payload.is_empty() => {
421                ev.category = Category::Protocol;
422                ev.subject = "Product name request".into();
423            }
424            0x60 => {
425                self.product = strings.first().cloned();
426                ev.subject = "Product".into();
427                ev.value = strings.first().cloned().unwrap_or_default();
428            }
429            _ => {
430                ev.category = Category::Unknown;
431                ev.subject = format!("Short SysEx 0x{kind:02X}");
432                ev.value = hex(payload);
433            }
434        }
435    }
436
437    fn apply_notify(&mut self, ev: &mut Event, what: u8, payload: &[u8]) {
438        match what {
439            0x19 => {
440                if payload.len() >= 6 {
441                    self.drum.pattern = Some(payload[1]);
442                    self.drum.bpm = Some(u14(payload[4], payload[5]));
443                }
444                ev.category = Category::Drum;
445                ev.subject = "Tempo".into();
446                ev.value = self.drum.bpm.map(|b| format!("{b} BPM")).unwrap_or_default();
447                ev.coalesce = key(&[ev.origin as u64, 9]);
448            }
449            0x0B => {
450                let i = payload.first().copied().unwrap_or(0);
451                ev.category = Category::Preset;
452                ev.subject = "Saved on pedal".into();
453                ev.value = preset_label(i);
454            }
455            0x6F => {
456                ev.category = Category::Tuner;
457                ev.subject = "Tuner".into();
458                ev.value = "toggled on pedal".into();
459            }
460            _ => {
461                ev.category = Category::Unknown;
462                ev.subject = format!("Notify 0x{what:02X} ({})", Cmd::label(what));
463                ev.value = hex(payload);
464            }
465        }
466    }
467
468    fn apply_nep(&mut self, ev: &mut Event, cmd: u8, dir: u8, payload: &[u8]) {
469        ev.category = Category::Protocol;
470        ev.subject = format!("{} {}", Cmd::label(cmd), Dir::label(dir));
471        let data = dir == Dir::Reply as u8 || dir == Dir::Write as u8;
472        let host_write = dir == Dir::Write as u8;
473        match Cmd::from_u8(cmd) {
474            Some(Cmd::Preset) if data => {
475                if let Some(p) = Preset::from_packed(payload) {
476                    let i = p.index;
477                    ev.value = format!("{} · {}", preset_label(i), p.name);
478                    if host_write {
479                        ev.category = Category::Preset;
480                        ev.subject = "Preset written".into();
481                    }
482                    let is_current = self.current == Some(i);
483                    if let Some(slot) = self.presets.get_mut(i as usize) {
484                        *slot = Some(p.clone());
485                    }
486                    if is_current && (!self.live_known || host_write) {
487                        self.live = p;
488                        self.live_known = true;
489                    }
490                }
491            }
492            Some(Cmd::EditBuffer) if data => {
493                if let Some(mut p) = Preset::from_packed(payload) {
494                    // The record's index field is always 0 here, and no read is known to return the
495                    // active slot, so identify it by matching against the stored presets.
496                    if self.current.is_none() {
497                        self.current = self
498                            .presets
499                            .iter()
500                            .position(|s| s.as_ref().is_some_and(|s| s.name == p.name && s.same_sound(&p)))
501                            .map(|i| i as u8);
502                    }
503                    p.index = self.current.unwrap_or(0);
504                    ev.value = p.name.clone();
505                    self.live = p;
506                    self.live_known = true;
507                }
508            }
509            Some(Cmd::PresetName) if host_write => {
510                let name = crate::records::ascii(payload.get(1..).unwrap_or_default());
511                ev.category = Category::Preset;
512                ev.subject = "Rename".into();
513                ev.value = format!("“{name}”");
514                self.live.name = name;
515            }
516            Some(Cmd::System) if data => {
517                if let Some(s) = Settings::from_packed(payload) {
518                    ev.value = format!(
519                        "trim {:+} dB · EQ {}/{}/{} · wireless {} · loopback {}",
520                        s.input_trim_db(),
521                        s.eq_bass,
522                        s.eq_mid,
523                        s.eq_treble,
524                        if s.wireless { "on" } else { "off" },
525                        if s.loopback { "on" } else { "off" },
526                    );
527                    if host_write {
528                        ev.category = Category::Settings;
529                        ev.subject = "System settings".into();
530                        ev.coalesce = key(&[ev.origin as u64, 10]);
531                    }
532                    self.settings = Some(s);
533                }
534            }
535            Some(Cmd::IrSlot) if data => {
536                if let Some((slot, name)) = parse_ir_slot(payload) {
537                    ev.value = format!("USER IR {:02} · {name}", slot + 1);
538                    if host_write {
539                        ev.category = Category::Effect(Module::Cab);
540                        ev.subject = "User IR slot".into();
541                    }
542                    if let Some(s) = self.user_irs.get_mut(slot as usize) {
543                        *s = Some(name);
544                    }
545                }
546            }
547            Some(Cmd::UserAmp) if dir == Dir::Reply as u8 || host_write => {
548                use crate::upload::{Chunk, UserAmpInfo, amp_sub};
549                let chunk = Chunk::parse(payload);
550                if let Some(info) = UserAmpInfo::parse(payload) {
551                    ev.value = format!("USER AMP {:02} · {}", info.slot + 1, info.name);
552                    if host_write {
553                        ev.category = Category::Effect(Module::Amp);
554                        ev.subject = "User AMP slot".into();
555                    }
556                    if let Some(s) = self.user_amps.get_mut(info.slot as usize) {
557                        *s = Some(info.name);
558                    }
559                } else if let Some(c) = chunk.filter(|c| c.sub == amp_sub::DELETE) {
560                    ev.category = Category::Effect(Module::Amp);
561                    ev.subject = "User AMP deleted".into();
562                    ev.value = format!("USER AMP {:02}", c.slot + 1);
563                } else if host_write {
564                    ev.subject = format!("{} upload", Cmd::label(cmd));
565                    ev.value = upload_progress(payload);
566                    ev.coalesce = key(&[ev.origin as u64, 11, cmd as u64]);
567                }
568            }
569            Some(Cmd::IrData) if host_write => {
570                ev.subject = format!("{} upload", Cmd::label(cmd));
571                ev.value = upload_progress(payload);
572                ev.coalesce = key(&[ev.origin as u64, 11, cmd as u64]);
573            }
574            Some(Cmd::Loop) if data => {
575                if payload.len() >= 4 {
576                    self.looper.rec_level = Some(payload[1]);
577                    self.looper.play_level = Some(payload[2]);
578                    self.looper.set(payload[3]);
579                    ev.value = format!(
580                        "{} · rec {} · play {}",
581                        self.looper.state_name(),
582                        payload[1],
583                        payload[2]
584                    );
585                }
586            }
587            Some(Cmd::Drum) if data => {
588                if payload.len() >= 6 {
589                    self.drum.pattern = Some(payload[1]);
590                    self.drum.level = Some(payload[2]);
591                    self.drum.bpm = Some(u14(payload[4], payload[5]));
592                    ev.value = format!(
593                        "{} · level {} · {} BPM",
594                        drum_pattern_name(payload[1]),
595                        payload[2],
596                        u14(payload[4], payload[5])
597                    );
598                }
599            }
600            Some(Cmd::Tuner) if data => {
601                if let Some(&s) = payload.first() {
602                    self.tuner.active = s != 0;
603                    ev.value = if s != 0 { "active" } else { "off" }.into();
604                }
605            }
606            _ => {
607                if !payload.is_empty() {
608                    let shown = &payload[..payload.len().min(16)];
609                    ev.value = hex(shown) + if payload.len() > 16 { " …" } else { "" };
610                }
611            }
612        }
613    }
614}