nord-format 0.3.0

Read and write Clavia / Nord keyboard file formats — programs, samples, set lists, settings, backups — with byte-exact round-trips
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
//! Sample instruments (`.nsmp`) — the Nord Sample Library format.
//!
//! Shared across the Nord line rather than specific to one model, so it carries its own
//! tag rather than a model's. A file is the CBIN header followed by a chain of tagged
//! [`section`]s: an `hdr` carrying the name, a `cat` of category strings, a `map`
//! ending in the [`zone`] table, one [`stroke`] per zone, and a trailing `sty`.
//!
//! Both container generations occur: across the corpus every v2 specimen is type 0 and
//! every v4 is type 1, while v3 is split. The container handles the difference; the
//! chain is the same.
//!
//! **Strokes are stored verbatim**, so this reads and rewrites instruments byte-exactly
//! and can retune, rename and remap them without touching a byte of audio. The [`codec`]
//! decodes that audio to samples in every generation — it is one codec in three sets of
//! units, so a caller only picks the right [`codec::Layout`]. [`encode`] builds a new
//! instrument from PCM, v2 only.

/// A zone and the stroke stream that plays it, ready for [`codec::decode`].
pub struct ZoneAudio<'a> {
    pub root_key: u8,
    pub top_note: u8,
    /// The stream's offset from the start of the body, which is the base its own
    /// word directory was written against.
    pub at: usize,
    pub stream: &'a [u8],
}

pub mod codec;
pub mod encode;
pub mod kernel;
pub mod section;
pub mod stroke;
pub mod zone;

pub use section::Section;
pub use stroke::Stroke;
pub use zone::Zone;
pub use zone::ZoneV3;

use crate::cbin::{self, BodyReader, BodyWriter, Cbin, Header};
use crate::error::{Error, ParseError};
use std::fmt;
use std::io::{Read, Seek, Write};

pub const FORMAT: &str = "nsmp";

/// The content version at which the body leaves the `NWS` chain for the wide
/// `NSMP` chain. All generations share the `nsmp` tag; the u32 at `0x14` is the
/// generation marker, running `format × 100 + revision` — `.nsmp3` content
/// stores 300 and up, `.nsmp4` 400 and up.
pub const V3_FROM_VERSION: u32 = 300;

/// The content version at which the wide chain becomes v4. Same chain and the same
/// stream units as v3 — what changes is the codec, so the number matters to
/// [`codec::Layout`] rather than to the reader.
pub const V4_FROM_VERSION: u32 = 400;

/// Content version of the first Sample Library whose v2 layout this reader decodes.
///
/// The number tracks the *library release*, not the codec, so the versions below this
/// are older libraries rather than older codecs — 8 above all, plus a 4/5/100/140/150
/// tail we hold no specimen of. They are still `NWS`-chain files; only what sits
/// inside the sections differs.
pub const LIBRARY_2_VERSION: u32 = 200;

/// A body decoded by generation: v2 in full, v3/v4 as a section chain with
/// strokes verbatim.
///
/// ⚠️ The v2 pool also holds versions that are not `2xx` — 8 (the original
/// Sample Library) and 200 (Sample Library 2.0; independent interop projects
/// report the number tracks the library release, not the codec) — so the gate
/// is "at least 300", not "exactly 2xx".
#[derive(Debug)]
pub enum AnyBody {
    V2(Sample),
    V3(SampleV3),
}

impl cbin::Body for AnyBody {
    fn read<R: Read + Seek>(r: &mut BodyReader<'_, R>, header: &Header) -> Result<Self, Error> {
        if header.version >= V3_FROM_VERSION {
            Ok(AnyBody::V3(<SampleV3 as cbin::Body>::read(r, header)?))
        } else {
            Ok(AnyBody::V2(<Sample as cbin::Body>::read(r, header)?))
        }
    }

    fn write<W: Write + Seek>(&self, w: &mut BodyWriter<'_, W>) -> Result<(), Error> {
        match self {
            AnyBody::V2(s) => <Sample as cbin::Body>::write(s, w),
            AnyBody::V3(s) => <SampleV3 as cbin::Body>::write(s, w),
        }
    }
}

/// Offset of the instrument name within the `hdr` payload.
const NAME_AT: usize = 12;

/// Longest name this writer will emit.
///
/// The field is fixed-width and NUL-padded — a 4-character and a 14-character name give
/// the same file length — but only 14 bytes have ever been observed in use, and what
/// follows the name inside `hdr` is unmapped. Writing a longer one risks overwriting a
/// field we cannot see, so refuse instead. Reading is unrestricted.
pub const MAX_NAME_LEN: usize = 14;

/// A sample instrument's body: the section chain, held in file order including
/// repeats — `stk` appears once per zone. A file is a `Cbin<Sample>`.
///
/// Reads and writes byte-exactly, checksum verified. The name, categories, zones
/// and stroke metadata decode and are editable; the audio stays verbatim.
pub struct Sample {
    pub sections: Vec<Section>,
}

impl cbin::Body for Sample {
    fn read<R: Read + Seek>(r: &mut BodyReader<'_, R>, _: &Header) -> Result<Self, Error> {
        Ok(Sample {
            sections: section::read_chain(r)?,
        })
    }

    fn write<W: Write + Seek>(&self, w: &mut BodyWriter<'_, W>) -> Result<(), Error> {
        for s in &self.sections {
            s.write_to(w)?;
        }
        Ok(())
    }
}

/// Reads a whole instrument, verifying its checksum.
pub fn read_from(reader: &mut (impl Read + Seek)) -> Result<Cbin<Sample>, Error> {
    cbin::read(reader, FORMAT)
}

/// A v3/v4 body: the wide-section (`NSMP`) chain, held in file order including
/// repeats — `stk` appears once per stroke. Sections are preserved verbatim, so
/// a file round-trips byte-exactly; nothing edits one yet.
///
/// Every corpus specimen chains `NSMP`, `hdr`, `cat`, `map`, N × `stk`, `sty`,
/// `meta`, in that order, in both container generations. Inferred from
/// specimens; not confirmed on hardware.
///
/// The stroke payloads are the encoded audio. The enclosing content version selects
/// [`codec::Layout::V3`] or [`codec::Layout::V4`] through [`codec::Layout::from_version`].
#[derive(Debug)]
pub struct SampleV3 {
    pub sections: Vec<section::Section4>,
}

impl cbin::Body for SampleV3 {
    fn read<R: Read + Seek>(r: &mut BodyReader<'_, R>, _: &Header) -> Result<Self, Error> {
        Ok(SampleV3 {
            sections: section::read_chain4(r)?,
        })
    }

    fn write<W: Write + Seek>(&self, w: &mut BodyWriter<'_, W>) -> Result<(), Error> {
        for s in &self.sections {
            s.write_to(w)?;
        }
        Ok(())
    }
}

/// Offset of the main name within the v3/v4 `hdr` payload.
const NAME_V3_AT: usize = 10;

/// End of the main-name field: the sub-name field starts here. The two fields
/// are what the filename convention joins — `Bass Clarinet 2` + `KG  mono` →
/// `Bass Clarinet 2_KG  mono 3.11`. Inferred from specimens; not confirmed on
/// hardware.
const NAME_V3_SUB_AT: usize = 76;

impl Cbin<SampleV3> {
    fn hdr(&self) -> Result<&section::Section4, Error> {
        section::find4(&self.body.sections, section::HDR4)
            .ok_or_else(|| ParseError::AssertFail("no hdr section".into()).into())
    }

    fn hdr_field(&self, from: usize, to: Option<usize>) -> Result<String, Error> {
        let hdr = self.hdr()?;
        let field = match to {
            Some(to) => hdr.payload.get(from..to),
            None => hdr.payload.get(from..),
        }
        .ok_or_else(|| {
            ParseError::AssertFail(format!("hdr section is {} bytes", hdr.payload.len()))
        })?;
        let end = field.iter().position(|&b| b == 0).unwrap_or(field.len());
        Ok(String::from_utf8_lossy(&field[..end]).into_owned())
    }

    /// The instrument's main name.
    pub fn name(&self) -> Result<String, Error> {
        self.hdr_field(NAME_V3_AT, Some(NAME_V3_SUB_AT))
    }

    /// The sub name — the string after the `_` in the vendor's filenames.
    /// Empty on files that carry none.
    pub fn sub_name(&self) -> Result<String, Error> {
        self.hdr_field(NAME_V3_SUB_AT, None)
    }

    /// How many strokes the body carries — one `stk` section each.
    pub fn stroke_count(&self) -> usize {
        self.body
            .sections
            .iter()
            .filter(|s| s.is(section::STK4))
            .count()
    }

    /// Each stroke's `(global id, root key)` — the u32 its payload leads with,
    /// and the byte at offset 5. Inferred from specimens; not confirmed on
    /// hardware.
    fn stroke_ids(&self) -> Result<Vec<(u32, u8)>, Error> {
        self.body
            .sections
            .iter()
            .filter(|s| s.is(section::STK4))
            .map(|s| match (s.payload.get(0..4), s.payload.get(5)) {
                (Some(gid), Some(&root)) => Ok((u32::from_be_bytes(gid.try_into().unwrap()), root)),
                _ => Err(ParseError::AssertFail(format!(
                    "stroke payload is {} bytes, too short for its id fields",
                    s.payload.len()
                ))
                .into()),
            })
            .collect()
    }

    /// Keyboard zones, in stored order — high to low except `map` v14, which
    /// stores low to high. Each zone is verified against the stroke it names.
    pub fn zones(&self) -> Result<Vec<ZoneV3>, Error> {
        let map = section::find4(&self.body.sections, section::MAP4)
            .ok_or_else(|| ParseError::AssertFail("no map section".into()))?;
        Ok(zone::read_v3(
            map.version,
            &map.payload,
            &self.stroke_ids()?,
        )?)
    }

    /// Every stroke's encoded stream with its offset from the start of the body, in
    /// file order.
    ///
    /// The offset is the base the stroke's own [`codec::Directory`] is written
    /// against, so a caller checking those pointers needs this pairing rather than
    /// the payload alone. Decode the streams with
    /// [`codec::Layout::from_version(self.header.version)`](codec::Layout::from_version).
    pub fn stroke_streams(&self) -> Vec<(usize, &[u8])> {
        let mut at = 0;
        let mut out = Vec::new();
        for section in &self.body.sections {
            if section.is(section::STK4) {
                out.push((at + section::HEADER4_LEN, section.payload.as_slice()));
            }
            at += section.encoded_len();
        }
        out
    }

    /// One zone's encoded stream, in [`Self::zones`] order. Decode it with
    /// [`codec::Layout::from_version(self.header.version)`](codec::Layout::from_version).
    ///
    /// Paired by the global id the zone record names, so it is safe on library
    /// content whose strokes are not in zone order.
    pub fn zone_stream(&self, index: usize) -> Result<(usize, &[u8]), Error> {
        let zones = self.zones()?;
        let zone = zones
            .get(index)
            .ok_or_else(|| ParseError::AssertFail(format!("no zone {index}")))?;
        let mut at = 0;
        for section in &self.body.sections {
            if section.is(section::STK4)
                && section
                    .payload
                    .get(0..4)
                    .map(|b| u32::from_be_bytes(b.try_into().unwrap()))
                    == Some(zone.stroke_gid)
            {
                return Ok((at + section::HEADER4_LEN, section.payload.as_slice()));
            }
            at += section.encoded_len();
        }
        Err(ParseError::AssertFail(format!(
            "zone {index} names stroke {}, which the file does not contain",
            zone.stroke_gid
        ))
        .into())
    }
}

pub fn from_bytes(bytes: &[u8]) -> Result<Cbin<Sample>, Error> {
    read_from(&mut std::io::Cursor::new(bytes))
}

impl Cbin<Sample> {
    /// Serializes, recomputing the checksum over the body it just produced.
    pub fn to_bytes(&self) -> Result<Vec<u8>, Error> {
        let mut out = std::io::Cursor::new(Vec::new());
        self.write_to(&mut out)?;
        Ok(out.into_inner())
    }

    /// Instrument name, as the Nord display shows it.
    ///
    /// The editor composes this from separate Main, Sub and Aux fields joined with `_`,
    /// so an empty Sub shows up as a doubled underscore rather than a typo.
    pub fn name(&self) -> Result<String, Error> {
        let hdr = self.hdr()?;
        let from = hdr.payload.get(NAME_AT..).ok_or_else(|| {
            ParseError::AssertFail(format!("hdr section is {} bytes", hdr.payload.len()))
        })?;
        let end = from.iter().position(|&b| b == 0).unwrap_or(from.len());
        Ok(String::from_utf8_lossy(&from[..end]).into_owned())
    }

    /// Renames in place, NUL-padding the rest of the field.
    pub fn set_name(&mut self, name: &str) -> Result<(), Error> {
        if name.len() > MAX_NAME_LEN {
            return Err(ParseError::OutOfBounds {
                value: format!("{name:?} ({} bytes)", name.len()),
                bound: format!("a name of at most {MAX_NAME_LEN} bytes"),
            }
            .into());
        }
        let hdr = section::find_mut(&mut self.body.sections, section::HDR)
            .ok_or_else(|| ParseError::AssertFail("no hdr section".into()))?;
        let field = hdr
            .payload
            .get_mut(NAME_AT..NAME_AT + MAX_NAME_LEN)
            .ok_or_else(|| ParseError::AssertFail("hdr section is too short for a name".into()))?;
        field.fill(0);
        field[..name.len()].copy_from_slice(name.as_bytes());
        Ok(())
    }

    /// Pre-2.0 libraries use a different zone layout; their section chain, name, and
    /// checksum remain readable.
    fn require_known_layout(&self) -> Result<(), Error> {
        if self.header.version < LIBRARY_2_VERSION {
            return Err(ParseError::AssertFail(format!(
                "content version {} predates Sample Library 2.0 and lays out its zone \
                 table differently; only the section chain and name are decoded",
                self.header.version
            ))
            .into());
        }
        Ok(())
    }

    /// Keyboard zones, high to low.
    pub fn zones(&self) -> Result<Vec<Zone>, Error> {
        self.require_known_layout()?;
        Ok(zone::read(&self.map()?.payload)?)
    }

    /// Sets one zone's top note. The strokes are untouched.
    pub fn set_zone_top_note(&mut self, index: usize, note: u8) -> Result<(), Error> {
        let map = section::find_mut(&mut self.body.sections, section::MAP)
            .ok_or_else(|| ParseError::AssertFail("no map section".into()))?;
        zone::set_top_note(&mut map.payload, index, note)?;
        Ok(())
    }

    /// One stroke per zone, **in [`Self::zones`] order** — which is not file order.
    ///
    /// Each zone names its stroke by id, and only instruments built in a single editor
    /// pass have those ids running parallel to the sections. Zipping this against
    /// `zones()` is therefore safe; indexing it as "the nth `stk` section" is not.
    pub fn strokes(&self) -> Result<Vec<Stroke>, Error> {
        self.require_known_layout()?;
        let zones = self.zones()?;
        let by_id = self.strokes_in_file_order()?;
        zones
            .iter()
            .map(|z| {
                by_id
                    .iter()
                    .find(|(id, _)| *id == u32::from(z.stroke_id))
                    .map(|(_, s)| *s)
                    .ok_or_else(|| {
                        ParseError::AssertFail(format!(
                            "zone reaching up to note {} names stroke {}, which the file \
                             does not contain",
                            z.top_note, z.stroke_id
                        ))
                        .into()
                    })
            })
            .collect()
    }

    /// Every stroke's encoded stream with its offset from the start of the body, in
    /// file order.
    ///
    /// The offset is the base the stroke's own [`codec::Directory`] is written
    /// against, so a caller checking those pointers needs this pairing rather than
    /// the payload alone.
    pub fn stroke_streams(&self) -> Vec<(usize, &[u8])> {
        let mut at = 0;
        let mut out = Vec::new();
        for section in &self.body.sections {
            if section.is(section::STK) {
                out.push((at + section::HEADER_LEN, section.payload.as_slice()));
            }
            at += section.encoded_len();
        }
        out
    }

    /// One zone's encoded stream, in [`Self::zones`] order, ready for
    /// [`codec::decode`].
    ///
    /// Paired by stroke id like [`Self::strokes`], so it is safe on library content
    /// that the editor did not build in a single pass.
    pub fn zone_stream(&self, index: usize) -> Result<(usize, &[u8]), Error> {
        let zones = self.zones()?;
        let zone = zones
            .get(index)
            .ok_or_else(|| ParseError::AssertFail(format!("no zone {index}")))?;
        let wanted = u32::from(zone.stroke_id);
        let mut at = 0;
        for section in &self.body.sections {
            if section.is(section::STK)
                && section
                    .payload
                    .get(0..4)
                    .map(|b| u32::from_be_bytes(b.try_into().unwrap()))
                    == Some(wanted)
            {
                return Ok((at + section::HEADER_LEN, section.payload.as_slice()));
            }
            at += section.encoded_len();
        }
        Err(ParseError::AssertFail(format!(
            "zone {index} names stroke {wanted}, which the file does not contain"
        ))
        .into())
    }

    /// Every stroke with the global id it carries, in the order the sections appear.
    ///
    /// The header length depends on a stroke's *position in the file*, so the read has
    /// to happen here, before anything reorders them.
    fn strokes_in_file_order(&self) -> Result<Vec<(u32, Stroke)>, Error> {
        // The first stroke's header is the remainder of a preamble it shares with
        // these two, so their sizes are what fixes where its audio starts.
        let map_len = self.map()?.payload.len();
        let cat_len = section::find(&self.body.sections, section::CAT)
            .map(|s| s.payload.len())
            .ok_or_else(|| ParseError::AssertFail("no cat section".into()))?;
        self.stroke_sections()
            .enumerate()
            .map(|(i, s)| {
                let id = s
                    .payload
                    .get(0..4)
                    .map(|b| u32::from_be_bytes(b.try_into().unwrap()))
                    .ok_or_else(|| {
                        ParseError::AssertFail(format!(
                            "stroke {i} is {} bytes, too short for its id",
                            s.payload.len()
                        ))
                    })?;
                Ok((id, stroke::read(&s.payload, i, cat_len, map_len)?))
            })
            .collect()
    }

    /// Retunes one zone by moving the note its sample plays untransposed at.
    ///
    /// `index` is into [`Self::zones`], matching [`Self::set_zone_top_note`] — so the
    /// stroke it reaches is the one that zone names, not the nth section. The two are
    /// the same file order only for instruments the editor built in a single pass.
    pub fn set_root_key(&mut self, index: usize, note: u8) -> Result<(), Error> {
        let zones = self.zones()?;
        let zone = zones
            .get(index)
            .ok_or_else(|| ParseError::AssertFail(format!("no zone {index}")))?;
        let wanted = u32::from(zone.stroke_id);
        let section = self
            .body
            .sections
            .iter_mut()
            .filter(|s| s.is(section::STK))
            .find(|s| {
                s.payload
                    .get(0..4)
                    .map(|b| u32::from_be_bytes(b.try_into().unwrap()))
                    == Some(wanted)
            })
            .ok_or_else(|| {
                ParseError::AssertFail(format!(
                    "zone {index} names stroke {wanted}, which the file does not contain"
                ))
            })?;
        stroke::set_root_key(&mut section.payload, note)?;
        Ok(())
    }

    /// Category labels, as stored in `cat`: length-prefixed strings.
    pub fn categories(&self) -> Vec<String> {
        let Some(cat) = section::find(&self.body.sections, section::CAT) else {
            return Vec::new();
        };
        let mut out = Vec::new();
        let mut i = 0;
        while i < cat.payload.len() {
            let len = cat.payload[i] as usize;
            let from = i + 1;
            // A length running past the end means this is not a string here; the
            // section holds a few leading bytes before the labels start.
            match cat.payload.get(from..from + len) {
                Some(s) if len > 0 && s.iter().all(|&b| (0x20..0x7f).contains(&b)) => {
                    out.push(String::from_utf8_lossy(s).into_owned());
                    i = from + len;
                }
                _ => i += 1,
            }
        }
        out
    }

    fn stroke_sections(&self) -> impl Iterator<Item = &Section> {
        self.body.sections.iter().filter(|s| s.is(section::STK))
    }

    fn hdr(&self) -> Result<&Section, Error> {
        section::find(&self.body.sections, section::HDR)
            .ok_or_else(|| ParseError::AssertFail("no hdr section".into()).into())
    }

    fn map(&self) -> Result<&Section, Error> {
        section::find(&self.body.sections, section::MAP)
            .ok_or_else(|| ParseError::AssertFail("no map section".into()).into())
    }
}

impl fmt::Debug for Sample {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("Sample")
            .field("sections", &self.sections)
            .finish()
    }
}