Skip to main content

open_stomp/
upload.rs

1//! User AMP (NAM, SysEx `0x58`) and user IR (SysEx `0x10` + `0x09`) uploads (docs/05).
2//!
3//! Both send a converted model as 2048 little-endian `f32`s (8192 bytes) in chunks of 136
4//! bytes, each a packed SysEx write acknowledged before the next. Every chunk starts with a
5//! 12-byte header:
6//!
7//! ```text
8//! [0] 0   [1] slot   [2] sub-command   [3] 0   [4..8] byte offset (u32 LE)   [8..12] total length (u32 LE)
9//! ```
10//!
11//! A user AMP upload is preceded by a 128-byte slot-info record (sub 3) carrying the name and a
12//! gain factor; a user IR upload is followed by a `0x09` name write. Converting a `.nam` or
13//! audio file into the 2048 floats is done by the `nam` and `ir` modules (docs/05).
14
15use crate::midi::{Cmd, Dir, frame};
16use crate::pack::{pack, unpack};
17
18/// Bytes of payload per chunk (34 floats).
19pub const CHUNK_BYTES: usize = 136;
20/// Floats per upload, as the official app sends them.
21pub const UPLOAD_FLOATS: usize = 2048;
22/// Number of user AMP slots (amp models 30–39) and user IR slots (cab models 30–39).
23pub const USER_SLOTS: u8 = 10;
24/// Model number of user slot 0 (USER AMP 01 / USER IR 01).
25pub const USER_MODEL_BASE: u8 = 30;
26
27/// Why an upload was refused.
28#[derive(Clone, Debug, PartialEq, Eq)]
29pub enum UploadError {
30    /// Slots are 0–9.
31    Slot(u8),
32    /// The pedal takes exactly [`UPLOAD_FLOATS`] floats.
33    Length(usize),
34    /// NaN or infinite values.
35    NotFinite,
36}
37
38impl std::fmt::Display for UploadError {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        match self {
41            UploadError::Slot(s) => write!(f, "user slot {s} out of range (0-9)"),
42            UploadError::Length(n) => write!(f, "{n} floats, the pedal takes exactly {UPLOAD_FLOATS}"),
43            UploadError::NotFinite => write!(f, "model data contains NaN or infinite values"),
44        }
45    }
46}
47
48impl std::error::Error for UploadError {}
49
50/// Check a slot and model data before uploading.
51pub fn check(slot: u8, data: &[f32]) -> Result<(), UploadError> {
52    if slot >= USER_SLOTS {
53        return Err(UploadError::Slot(slot));
54    }
55    if data.len() != UPLOAD_FLOATS {
56        return Err(UploadError::Length(data.len()));
57    }
58    if !data.iter().all(|f| f.is_finite()) {
59        return Err(UploadError::NotFinite);
60    }
61    Ok(())
62}
63
64/// `0x58` sub-commands (unpacked header byte 2).
65pub mod amp_sub {
66    pub const DATA: u8 = 1;
67    pub const WRITE_INFO: u8 = 3;
68    pub const READ_INFO: u8 = 4;
69    pub const DELETE: u8 = 6;
70}
71
72/// `0x10` sub-command for IR data.
73pub const IR_DATA_SUB: u8 = 0;
74
75/// One upload chunk (or a header-only request/ack), unpacked.
76#[derive(Clone, Debug, PartialEq, Eq)]
77pub struct Chunk {
78    /// Header byte 0: the user IR slot in IR data (`0x10`) chunks, 0 in NAM uploads.
79    pub ir_slot: u8,
80    /// Header byte 1: the user AMP slot in NAM (`0x58`) chunks, 0 in IR data.
81    pub slot: u8,
82    pub sub: u8,
83    /// Byte offset of `data` in the whole upload.
84    pub offset: u32,
85    /// Total upload length in bytes (8192 for model data, 128 for a slot-info record).
86    pub total: u32,
87    pub data: Vec<u8>,
88}
89
90impl Chunk {
91    /// Decode a packed `0x58`/`0x10` body (the bytes after `<dir>`).
92    pub fn parse(payload: &[u8]) -> Option<Chunk> {
93        let raw = unpack(payload);
94        let h = raw.get(..12)?;
95        Some(Chunk {
96            ir_slot: h[0],
97            slot: h[1],
98            sub: h[2],
99            offset: u32::from_le_bytes(h[4..8].try_into().ok()?),
100            total: u32::from_le_bytes(h[8..12].try_into().ok()?),
101            data: raw[12..].to_vec(),
102        })
103    }
104
105    fn header(ir_slot: u8, slot: u8, sub: u8, offset: u32, total: u32) -> [u8; 12] {
106        let mut h = [0u8; 12];
107        h[0] = ir_slot;
108        h[1] = slot;
109        h[2] = sub;
110        h[4..8].copy_from_slice(&offset.to_le_bytes());
111        h[8..12].copy_from_slice(&total.to_le_bytes());
112        h
113    }
114
115    /// The packed body: header + data.
116    pub fn to_packed(&self) -> Vec<u8> {
117        let mut raw = Self::header(self.ir_slot, self.slot, self.sub, self.offset, self.total).to_vec();
118        raw.extend_from_slice(&self.data);
119        pack(&raw)
120    }
121}
122
123/// Split `data` into `CHUNK_BYTES` write frames for `cmd` (`0x58` NAM data, `0x10` IR data). The
124/// slot goes where each command expects it: header byte 1 for NAM, byte 0 for IR data.
125pub fn data_frames(cmd: Cmd, slot: u8, sub: u8, data: &[u8]) -> Vec<Vec<u8>> {
126    let total = data.len() as u32;
127    let (ir_slot, slot) = if cmd == Cmd::IrData { (slot, 0) } else { (0, slot) };
128    data.chunks(CHUNK_BYTES)
129        .enumerate()
130        .map(|(i, part)| {
131            let c = Chunk { ir_slot, slot, sub, offset: (i * CHUNK_BYTES) as u32, total, data: part.to_vec() };
132            frame(cmd, Dir::Write, &c.to_packed())
133        })
134        .collect()
135}
136
137/// Model data as the little-endian bytes the pedal receives.
138pub fn floats_to_bytes(v: &[f32]) -> Vec<u8> {
139    v.iter().flat_map(|f| f.to_le_bytes()).collect()
140}
141
142pub fn bytes_to_floats(b: &[u8]) -> Vec<f32> {
143    b.as_chunks::<4>().0.iter().map(|c| f32::from_le_bytes(*c)).collect()
144}
145
146/// Collects the data chunks of one upload (in any order) back into its floats.
147#[derive(Clone, Debug, Default)]
148pub struct Reassembler {
149    buf: Vec<u8>,
150    filled: usize,
151    slot: Option<u8>,
152}
153
154impl Reassembler {
155    /// Add a data chunk (NAM or IR). Returns the slot and floats once every byte has arrived.
156    pub fn push(&mut self, c: &Chunk) -> Option<(u8, Vec<f32>)> {
157        let (start, total) = (c.offset as usize, c.total as usize);
158        // the NAM slot or the IR slot, whichever header byte carries it
159        let slot = c.slot | c.ir_slot;
160        // a chunk at offset 0, or for another slot/size, starts a new upload
161        if start == 0 || self.slot != Some(slot) || self.buf.len() != total {
162            self.buf = vec![0; total];
163            self.filled = 0;
164            self.slot = Some(slot);
165        }
166        let end = (start + c.data.len()).min(total);
167        if start >= end {
168            return None;
169        }
170        self.buf[start..end].copy_from_slice(&c.data[..end - start]);
171        self.filled += end - start;
172        (self.filled >= total).then(|| {
173            self.filled = 0;
174            (slot, bytes_to_floats(&self.buf))
175        })
176    }
177}
178
179pub const AMP_INFO_LEN: usize = 128;
180const AMP_NAME_LEN: usize = 32;
181const AMP_FACTOR_AT: usize = 52;
182
183/// The 128-byte user AMP slot-info record (`0x58` sub 3 write, sub 4 reply), kept whole so
184/// unknown bytes survive.
185///
186/// Known: bytes 0–31 name (ASCII, zero-padded), 32–35 `u32` = 256 in every record seen,
187/// 52–55 `f32` factor (1.0 in both captured imports). An empty slot reads back as
188/// `"USER AMP NN"` with `1.0` in each of the eleven floats at 52–95.
189#[derive(Clone, Debug, PartialEq)]
190pub struct UserAmpInfo {
191    pub slot: u8,
192    pub name: String,
193    pub factor: f32,
194    pub raw: [u8; AMP_INFO_LEN],
195}
196
197impl UserAmpInfo {
198    /// The record the official app writes for a fresh import.
199    pub fn new(slot: u8, name: &str, factor: f32) -> Self {
200        let mut raw = [0u8; AMP_INFO_LEN];
201        raw[32..36].copy_from_slice(&256u32.to_le_bytes());
202        UserAmpInfo { slot, name: name.to_owned(), factor, raw }
203    }
204
205    /// Decode a sub 3 write or sub 4 reply body.
206    pub fn parse(payload: &[u8]) -> Option<Self> {
207        let c = Chunk::parse(payload)?;
208        if c.sub != amp_sub::WRITE_INFO && c.sub != amp_sub::READ_INFO {
209            return None;
210        }
211        let raw: [u8; AMP_INFO_LEN] = c.data.get(..AMP_INFO_LEN)?.try_into().ok()?;
212        Some(UserAmpInfo {
213            slot: c.slot,
214            name: crate::records::ascii(&raw[..AMP_NAME_LEN]),
215            factor: f32::from_le_bytes(raw[AMP_FACTOR_AT..AMP_FACTOR_AT + 4].try_into().ok()?),
216            raw,
217        })
218    }
219
220    pub fn to_raw(&self) -> [u8; AMP_INFO_LEN] {
221        let mut raw = self.raw;
222        raw[..AMP_NAME_LEN].copy_from_slice(&name_field::<AMP_NAME_LEN>(&self.name));
223        raw[AMP_FACTOR_AT..AMP_FACTOR_AT + 4].copy_from_slice(&self.factor.to_le_bytes());
224        raw
225    }
226
227    /// The sub 3 write frame.
228    pub fn write_frame(&self) -> Vec<u8> {
229        let c = Chunk {
230            ir_slot: 0,
231            slot: self.slot,
232            sub: amp_sub::WRITE_INFO,
233            offset: 0,
234            total: AMP_INFO_LEN as u32,
235            data: self.to_raw().to_vec(),
236        };
237        frame(Cmd::UserAmp, Dir::Write, &c.to_packed())
238    }
239}
240
241/// A name as an `N`-byte zero-padded ASCII field (non-ASCII becomes `?`, one byte kept for NUL).
242fn name_field<const N: usize>(name: &str) -> [u8; N] {
243    let mut b = [0u8; N];
244    for (d, c) in b.iter_mut().take(N - 1).zip(name.chars()) {
245        *d = if (' '..='~').contains(&c) { c as u8 } else { b'?' };
246    }
247    b
248}
249
250/// The official app's user AMP import: slot-info record, then the model data. The app selects
251/// the slot's amp model (`host::head(Module::Amp, 30 + slot, true)`) just before.
252pub fn user_amp_upload(info: &UserAmpInfo, data: &[f32]) -> Vec<Vec<u8>> {
253    let mut v = vec![info.write_frame()];
254    v.extend(data_frames(Cmd::UserAmp, info.slot, amp_sub::DATA, &floats_to_bytes(data)));
255    v
256}
257
258/// Delete a user AMP slot (the app re-reads its info afterwards).
259pub fn user_amp_delete(slot: u8) -> Vec<u8> {
260    let c = Chunk { ir_slot: 0, slot, sub: amp_sub::DELETE, offset: 0, total: 0, data: vec![] };
261    frame(Cmd::UserAmp, Dir::Write, &c.to_packed())
262}
263
264const IR_NAME_LEN: usize = 32;
265
266/// The `0x09` user IR name write: `<slot> <packed: 01 00 00 00, 32-byte name>`. The first field
267/// was `1` after an upload (meaning unknown, docs/09).
268pub fn user_ir_name(slot: u8, name: &str) -> Vec<u8> {
269    let mut raw = vec![1, 0, 0, 0];
270    raw.extend_from_slice(&name_field::<IR_NAME_LEN>(name));
271    let mut p = vec![slot & 0x7F];
272    p.extend(pack(&raw));
273    frame(Cmd::IrSlot, Dir::Write, &p)
274}
275
276/// The official app's user IR import: the data (slot in header byte 0, unlike NAM's byte 1), then
277/// the name. Checked byte for byte against imports into all ten slots
278/// (`captures/logs/02-ir-tests.log`). The app selects the slot's cab model
279/// (`host::head(Module::Cab, 30 + slot, true)`) just before.
280pub fn user_ir_upload(slot: u8, name: &str, data: &[f32]) -> Vec<Vec<u8>> {
281    let mut v = data_frames(Cmd::IrData, slot, IR_DATA_SUB, &floats_to_bytes(data));
282    v.push(user_ir_name(slot, name));
283    v
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use crate::midi::{Msg, hex};
290
291    #[test]
292    fn chunking() {
293        let data: Vec<f32> = (0..UPLOAD_FLOATS).map(|i| i as f32 * 0.5).collect();
294        let frames = data_frames(Cmd::UserAmp, 2, amp_sub::DATA, &floats_to_bytes(&data));
295        assert_eq!(frames.len(), 61);
296        let mut r = Reassembler::default();
297        let mut out = None;
298        for f in &frames {
299            let Msg::Nep { cmd: 0x58, dir: 1, payload } = Msg::parse(f) else { panic!() };
300            let c = Chunk::parse(&payload).unwrap();
301            assert_eq!((c.slot, c.sub, c.total), (2, 1, 8192));
302            out = r.push(&c);
303        }
304        assert_eq!(out, Some((2, data)));
305        // last chunk: offset 8160, 32 bytes
306        let Msg::Nep { payload, .. } = Msg::parse(frames.last().unwrap()) else { panic!() };
307        let c = Chunk::parse(&payload).unwrap();
308        assert_eq!((c.offset, c.data.len()), (8160, 32));
309    }
310
311    #[test]
312    fn info_round_trip() {
313        let i = UserAmpInfo::new(2, "Jcm800", 1.0);
314        let Msg::Nep { payload, .. } = Msg::parse(&i.write_frame()) else { panic!() };
315        let p = UserAmpInfo::parse(&payload).unwrap();
316        assert_eq!(p, UserAmpInfo { raw: i.to_raw(), ..i });
317        assert_eq!(hex(&user_amp_delete(0)), "F0 00 21 7A 70 58 01 00 00 18 00 00 00 00 00 00 00 00 00 00 00 F7");
318    }
319
320    /// A rename is the import's name record on its own: the AMP slot-info write that starts an
321    /// import, the IR name write that ends one (`Device::rename_user_amp` / `rename_user_ir`).
322    #[test]
323    fn rename_is_the_imports_name_record() {
324        let data = vec![0.25f32; UPLOAD_FLOATS];
325        let amp = user_amp_upload(&UserAmpInfo::new(4, "SVT G10 .5", 1.0), &data);
326        assert_eq!(UserAmpInfo::new(4, "SVT G10 .5", 1.0).write_frame(), amp[0]);
327        let ir = user_ir_upload(7, "SVT Neumann .5", &data);
328        assert_eq!(&user_ir_name(7, "SVT Neumann .5"), ir.last().unwrap());
329        // over-long and non-ASCII names: cut to 31 characters, '?' for anything else
330        let frame = UserAmpInfo::new(0, &"é".repeat(40), 1.0).write_frame();
331        let Msg::Nep { payload, .. } = Msg::parse(&frame) else { panic!() };
332        assert_eq!(UserAmpInfo::parse(&payload).unwrap().name, "?".repeat(31));
333    }
334
335}