Skip to main content

nord_format/formats/nsmp/
keymap.rs

1//! The keyboard map ahead of the zone table in a v2 `map` section: the
2//! instrument's own gain and detune, then one gain-and-detune record per MIDI
3//! note.
4//!
5//! Nothing here touches the audio — a record is a playback-side level and
6//! pitch offset for one key, and the instrument's record scales the whole map.
7//! The sample editor writes the per-key records from its note list; its macro
8//! spin controls have no storage of their own and write through to the same
9//! table.
10//!
11//! Inferred from specimens; not confirmed on hardware.
12
13use super::zone;
14use crate::error::ParseError;
15
16/// Schema version of a `map` section on the Sample Library 2.0 narrow chain.
17pub(super) const VERSION: u8 = 10;
18
19/// Schema version of a `map` section on the chain before it. The zone table behind
20/// this keyboard map is narrower there; the keyboard map itself is byte for byte the
21/// same layout, filler included.
22pub(super) const VERSION_EARLY: u8 = 9;
23
24/// `1.0` in every gain field of the map, a u24 linear ratio — the same unit and the
25/// same value the zone record's own gain field uses.
26pub const GAIN_UNITY: u32 = zone::GAIN_UNITY;
27
28/// Largest value a gain field holds.
29pub const GAIN_MAX: u32 = 0xFF_FFFF;
30
31/// One semitone in every detune field of the map, an s24 count of 1/256
32/// semitone. The same unit serves the zone record's own detune.
33pub const DETUNE_PER_SEMITONE: i32 = 256;
34
35const DETUNE_MIN: i32 = -(1 << 23);
36const DETUNE_MAX: i32 = (1 << 23) - 1;
37
38/// One record per MIDI note.
39pub const KEYS: usize = 128;
40
41/// Bytes per record: a u24 gain then an s24 detune, both big-endian.
42pub const RECORD_LEN: usize = 6;
43
44/// The instrument's own record, at the head of the payload.
45const LEVEL_AT: usize = 0;
46
47/// Where note 0's record sits; note `n` is `KEY_TABLE_AT + RECORD_LEN * n`.
48pub const KEY_TABLE_AT: usize = 15;
49
50const _: () = assert!(KEY_TABLE_AT + KEYS * RECORD_LEN + 2 == zone::COUNT_AT);
51
52/// A gain and a pitch offset — the six-byte record the map is built from.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub struct Level {
55    /// Linear gain, [`GAIN_UNITY`] for 1.0; never more than [`GAIN_MAX`].
56    ///
57    /// The editor keeps a key's gain within ±9 dB and the instrument's below
58    /// +9 dB, clamping as it encodes without repairing its project. What the
59    /// instrument does with a value outside that band is unmeasured.
60    gain: u32,
61    /// Pitch offset in 1/256 semitone, positive upward, within an s24.
62    ///
63    /// The editor truncates its cents toward zero: 1 cent stores 2, 8 cents
64    /// store 20, and a whole octave stores 3072.
65    detune: i32,
66}
67
68impl Level {
69    /// Unity gain, no detune — what every record holds until it is set.
70    pub const NEUTRAL: Level = Level {
71        gain: GAIN_UNITY,
72        detune: 0,
73    };
74
75    /// Checks both fields fit their 24 bits.
76    pub fn new(gain: u32, detune: i32) -> Result<Level, ParseError> {
77        if gain > GAIN_MAX {
78            return Err(ParseError::AssertFail(format!(
79                "gain {gain:#x} does not fit the 24-bit field (max {GAIN_MAX:#x})"
80            )));
81        }
82        if !(DETUNE_MIN..=DETUNE_MAX).contains(&detune) {
83            return Err(ParseError::AssertFail(format!(
84                "detune {detune} does not fit the 24-bit field ({DETUNE_MIN}..={DETUNE_MAX})"
85            )));
86        }
87        Ok(Level { gain, detune })
88    }
89
90    /// From a linear gain ratio and a detune in semitones. Gain is rounded to the
91    /// nearest field unit; detune is truncated toward zero as the editor writes it.
92    pub fn from_ratio(gain: f64, semitones: f64) -> Result<Level, ParseError> {
93        let units = gain * f64::from(GAIN_UNITY);
94        if !units.is_finite() || units < 0.0 || units > f64::from(GAIN_MAX) {
95            return Err(ParseError::AssertFail(format!(
96                "gain ratio {gain} is outside what the 24-bit field holds"
97            )));
98        }
99        let detune = semitones * f64::from(DETUNE_PER_SEMITONE);
100        if !detune.is_finite() || detune < f64::from(DETUNE_MIN) || detune > f64::from(DETUNE_MAX) {
101            return Err(ParseError::AssertFail(format!(
102                "detune of {semitones} semitones is outside what the 24-bit field holds"
103            )));
104        }
105        Level::new(units.round() as u32, detune.trunc() as i32)
106    }
107
108    /// The gain field's fixed-point value.
109    pub const fn gain(self) -> u32 {
110        self.gain
111    }
112
113    /// The detune field's signed 1/256-semitone value.
114    pub const fn detune(self) -> i32 {
115        self.detune
116    }
117
118    /// The gain as a linear ratio, 1.0 for unity.
119    pub fn ratio(&self) -> f64 {
120        f64::from(self.gain) / f64::from(GAIN_UNITY)
121    }
122
123    /// The detune in semitones.
124    pub fn semitones(&self) -> f64 {
125        f64::from(self.detune) / f64::from(DETUNE_PER_SEMITONE)
126    }
127
128    fn read(record: &[u8]) -> Level {
129        let gain = u32::from_be_bytes([0, record[0], record[1], record[2]]);
130        let raw = u32::from_be_bytes([0, record[3], record[4], record[5]]);
131        // Sign-extend the s24.
132        let detune = ((raw << 8) as i32) >> 8;
133        Level { gain, detune }
134    }
135
136    fn write(&self, record: &mut [u8]) {
137        record[..3].copy_from_slice(&self.gain.to_be_bytes()[1..]);
138        record[3..RECORD_LEN].copy_from_slice(&(self.detune as u32).to_be_bytes()[1..]);
139    }
140}
141
142/// The whole keyboard map: the instrument's record and one per MIDI note.
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct KeyTable {
145    /// Scales and detunes the whole map.
146    pub instrument: Level,
147    /// Indexed by MIDI note.
148    keys: [Level; KEYS],
149}
150
151impl KeyTable {
152    /// Every record neutral — what the editor writes for an untouched map.
153    pub const NEUTRAL: KeyTable = KeyTable {
154        instrument: Level::NEUTRAL,
155        keys: [Level::NEUTRAL; KEYS],
156    };
157
158    /// Reads the map ahead of the zone count.
159    pub fn read(map: &[u8]) -> Result<KeyTable, ParseError> {
160        fits(map)?;
161        let mut keys = [Level::NEUTRAL; KEYS];
162        for (note, key) in keys.iter_mut().enumerate() {
163            *key = Level::read(&map[record_at(note)..][..RECORD_LEN]);
164        }
165        Ok(KeyTable {
166            instrument: Level::read(&map[LEVEL_AT..][..RECORD_LEN]),
167            keys,
168        })
169    }
170
171    /// Writes the map ahead of the zone count, leaving the zone table alone.
172    pub fn write(&self, map: &mut [u8]) -> Result<(), ParseError> {
173        fits(map)?;
174        self.instrument.write(&mut map[LEVEL_AT..][..RECORD_LEN]);
175        for (note, key) in self.keys.iter().enumerate() {
176            key.write(&mut map[record_at(note)..][..RECORD_LEN]);
177        }
178        Ok(())
179    }
180
181    /// The bytes ahead of the zone count, ready to head a new `map` payload.
182    pub fn prefix(&self) -> [u8; zone::COUNT_AT] {
183        let mut out = [0u8; zone::COUNT_AT];
184        self.write(&mut out)
185            .expect("a buffer sized to the zone count holds the whole keyboard map");
186        out
187    }
188
189    /// The record for one MIDI note.
190    pub fn key(&self, note: u8) -> Result<Level, ParseError> {
191        self.keys
192            .get(usize::from(note))
193            .copied()
194            .ok_or_else(|| ParseError::OutOfBounds {
195                value: format!("MIDI note {note}"),
196                bound: "a MIDI note from 0 through 127".into(),
197            })
198    }
199
200    /// Sets the record for one MIDI note.
201    pub fn set_key(&mut self, note: u8, level: Level) -> Result<(), ParseError> {
202        let key = self
203            .keys
204            .get_mut(usize::from(note))
205            .ok_or_else(|| ParseError::OutOfBounds {
206                value: format!("MIDI note {note}"),
207                bound: "a MIDI note from 0 through 127".into(),
208            })?;
209        *key = level;
210        Ok(())
211    }
212
213    /// Notes whose record is not neutral.
214    pub fn adjusted(&self) -> impl Iterator<Item = u8> + '_ {
215        self.keys
216            .iter()
217            .enumerate()
218            .filter(|(_, level)| **level != Level::NEUTRAL)
219            .map(|(note, _)| note as u8)
220    }
221}
222
223const fn record_at(note: usize) -> usize {
224    KEY_TABLE_AT + RECORD_LEN * note
225}
226
227fn fits(map: &[u8]) -> Result<(), ParseError> {
228    if map.len() < zone::COUNT_AT {
229        return Err(ParseError::AssertFail(format!(
230            "map section is {} bytes, too short for a keyboard map",
231            map.len()
232        )));
233    }
234    Ok(())
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn neutral_prefix_is_unity_at_every_record_and_zero_between() {
243        let prefix = KeyTable::NEUTRAL.prefix();
244        assert_eq!(prefix.len(), zone::COUNT_AT);
245        for (i, b) in prefix.iter().enumerate() {
246            let expected = if i == LEVEL_AT
247                || (i >= KEY_TABLE_AT
248                    && (i - KEY_TABLE_AT).is_multiple_of(RECORD_LEN)
249                    && i < KEY_TABLE_AT + KEYS * RECORD_LEN)
250            {
251                0x10
252            } else {
253                0
254            };
255            assert_eq!(*b, expected, "byte {i}");
256        }
257    }
258
259    #[test]
260    fn records_round_trip_through_their_bytes() {
261        let mut table = KeyTable::NEUTRAL;
262        table.instrument = Level::new(0x2d_1819, -256).unwrap();
263        table
264            .set_key(60, Level::new(0x08_0000, 20).unwrap())
265            .unwrap();
266        table
267            .set_key(17, Level::new(0, -(1 << 23)).unwrap())
268            .unwrap();
269        table
270            .set_key(127, Level::new(GAIN_MAX, (1 << 23) - 1).unwrap())
271            .unwrap();
272        let mut map = vec![0xAA; zone::COUNT_AT + 1 + zone::RECORD_LEN];
273        table.write(&mut map).unwrap();
274        assert_eq!(KeyTable::read(&map).unwrap(), table);
275        assert!(map[LEVEL_AT + RECORD_LEN..KEY_TABLE_AT]
276            .iter()
277            .all(|&b| b == 0xAA));
278        assert!(map[KEY_TABLE_AT + KEYS * RECORD_LEN..zone::COUNT_AT]
279            .iter()
280            .all(|&b| b == 0xAA));
281        assert!(map[zone::COUNT_AT..].iter().all(|b| *b == 0xAA));
282        assert_eq!(table.adjusted().collect::<Vec<_>>(), [17, 60, 127]);
283    }
284
285    #[test]
286    fn detune_reads_signed() {
287        let mut map = vec![0u8; zone::COUNT_AT];
288        map[record_at(60) + 3..record_at(60) + 6].copy_from_slice(&[0xff, 0xff, 0xec]);
289        assert_eq!(KeyTable::read(&map).unwrap().key(60).unwrap().detune(), -20);
290    }
291
292    #[test]
293    fn fields_are_checked_against_their_width() {
294        assert!(Level::new(GAIN_MAX + 1, 0).is_err());
295        assert!(Level::new(0, 1 << 23).is_err());
296        assert!(Level::new(0, -(1 << 23) - 1).is_err());
297        assert_eq!(
298            Level::from_ratio(0.5, -1.0).unwrap(),
299            Level::new(0x08_0000, -256).unwrap()
300        );
301        assert_eq!(Level::from_ratio(1.0, 0.01).unwrap().detune(), 2);
302        assert_eq!(Level::from_ratio(1.0, -0.01).unwrap().detune(), -2);
303        assert!(Level::from_ratio(16.0, 0.0).is_err());
304        assert!(Level::from_ratio(-0.5, 0.0).is_err());
305        assert!(Level::from_ratio(1.0, f64::NAN).is_err());
306    }
307
308    #[test]
309    fn short_maps_and_notes_outside_midi_are_refused() {
310        let map = KeyTable::NEUTRAL.prefix().to_vec();
311        assert!(KeyTable::read(&map[..700]).is_err());
312        assert!(KeyTable::NEUTRAL.key(128).is_err());
313        let mut table = KeyTable::NEUTRAL;
314        assert!(table.set_key(128, Level::NEUTRAL).is_err());
315    }
316}