Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

drawbar

drawbar reads, edits and moves the sounds on a Nord keyboard. It comes as an app that runs in your browser or on your desktop, and as nord, a command for your terminal.

Alpha software. Back up your instrument and your files before you use it. The file formats and the USB protocol were worked out by studying real instruments. What is supported lists what has been tested on hardware.

Open drawbar at drawbar.app in Chrome or Edge. Install covers the desktop app and nord.

In this guide

  • drawbar is the app. Start with The window.
  • nord-cli is the nord command. Start with its overview.
  • Reference is for developers: building from source, the file formats, the USB protocol, and how to contribute.

Both tools are built on two Rust libraries you can use in your own projects: nord-format for files and nord-usb for the instrument. The source is on GitHub.

Disclaimer

Not affiliated with, authorized, or endorsed by Clavia DMI AB. “Nord”, “Clavia”, and “Electro” are trademarks of Clavia DMI AB, used here only to identify the hardware these formats come from. Proprietary sound libraries and firmware are not distributed here.

Install

In the browser

Open drawbar.app. Nothing to install, and your files stay in the browser’s own storage. Chrome and Edge can keep them in a folder on your computer instead.

Only Chrome and Edge can connect to an instrument. Files work in any browser. Which browsers can play the key maps from a MIDI controller is on What is supported.

Close Nord Sound Manager before connecting, since it keeps the instrument to itself.

On the desktop

With Nix and flakes enabled:

nix run github:jmoo/drawbar#drawbar

Or build it with Cargo. On Linux this needs the ALSA headers and pkg-config.

cargo install drawbar

The nord command

cargo install nord-cli                           # installs `nord`
nix run github:jmoo/drawbar#nord-cli -- --help   # or run it with Nix

There are no prebuilt downloads yet. To build either from a source checkout, see Building from source.

If drawbar does not load

The loading page says which step failed and offers to try again. The usual causes:

  • The connection dropped, or the download stopped partway. Reload the page.
  • The browser lacks WebAssembly or WebGL2, which drawbar needs. Every current browser has both. If the page says WebGL isn’t supported, turn on hardware acceleration in the browser’s settings and restart the browser.
  • An extension or a content blocker stopped drawbar. Allow this site and reload.

What is supported

drawbar was developed against a Nord Electro 5, and that is where its claims have been tested. Files from other instruments are supported from documentation and sample files. Connecting other instruments over USB has not been tried.

Files

FilesWhat drawbar doesTested on an instrument
Electro 5 programs, live slots, set lists and settingsView, edit, transferYes
Stage 2, 3 and 4 programs and live slots, and Stage 3 and 4 presetsView, editNo
Sample instruments (.nsmp, .nsmp3, .nsmp4)Decode, edit, encode, audition, transferPlayback of v2 files. Files encoded as v3 or v4 have not been played
Piano libraries (.npno)Decode, trim, split, rename, retune, remap, build from WAVs with loops, transferTrimmed, built and re-encoded libraries play, mono and stereo, across every key they cover. A library built without a template sounds the same as one built with one while a key is held, and fades a note out within about 30 ms of key-up. Loops built from WAVs sustain on an Electro 5 until the key is released, mono and stereo, on their own keys and transposed ones, and fade at the rate their kind and --loop-fade set. Loops built by hand in the same layout sustain on a Stage 4. Libraries are written in format 6.x unless 5.x is chosen. An Electro 5 on OS 2.04 plays both formats, built by rules or against a 6.x template, with their velocity layers, trims, releases and loops. A release plays at the level the note has fallen to at key-up. The velocities drawbar gives for each layer are an Electro 5’s. Renames, retunes, remaps and a narrowed key range have not been played
Electro 5 bundles (.ne5pbundle, .ne5tbundle)Import to a folder, export from this computer or the instrument. Only uncompressed bundles open, as Nord Sound Manager writes themNord Sound Manager opens the bundles drawbar writes and sends them to the instrument. Bundles made from the instrument match what nord-cli makes. A piano or sample built in drawbar has not yet gone to the instrument in a bundle
Other Nord filesRecognized and kept byte for byte, without editing
Text filesEdit, keep beside your filesNever sent to an instrument

USB

Reading and writing programs, set lists, live slots, settings, samples and pianos works on the Electro 5 from macOS, Linux and the browser. Windows builds pass the protocol tests, and drawbar on Windows has connected to a Stage 4 and listed its storage, but nothing has been written from Windows. The Stage 4 is the only other instrument connected so far, and no file has been read from it or written to it, so USB support for other models cannot be guaranteed.

drawbar and nord-cli find programs, samples and the rest by the name the instrument gives each kind of storage, so another model’s folders show what it keeps in them. The Stage 4’s organ, piano and synth presets get folders of their own. A kind of storage drawbar has no name for, such as the Stage 4’s piano pedal sounds, is listed under the instrument’s own name and is read only. Writing to another model follows what the Electro 5 does and has not been tried, so drawbar and nord-cli warn before every such write.

The safeguards drawbar applies when it writes, listed under Safety, have been confirmed on the Electro 5.

MIDI controllers

The sample and piano key maps can be played from a MIDI controller. The desktop app listens on every MIDI input the computer has. In the browser, Chrome, Edge and Firefox can hear a controller. Safari cannot, so drawbar grays out the menu item there. Firefox asks you to allow MIDI access for the site first.

What “tested” means

Every file drawbar writes is read back and checked to be identical, byte for byte. That proves the bytes survive. It does not prove that every value means what drawbar says, or that every new sound plays as intended. Where a claim has been confirmed by playing it on an instrument, this page says so.

Developers can read how the checks work in Testing and File formats.

The window

drawbar is one window over two places: the files on your computer and the sounds on your instrument. Open it at drawbar.app, or run the desktop app.

PartWhat it is for
Top barOpen, New and Save, the search box, Send, and Connect instrument… or your instrument’s name.
Browser (left)This computer and, once connected, the instrument’s folders. Kinds and Tags narrow the list.
Library (center)One table over both places. Sort by any column. In a narrow window, other columns hide before the names shorten.
Documents (center)Every sound you open gets a tab.
Keyboard (center)The instrument’s slots, bank by bank, once connected.
Inspector (right)What is selected and what you can do with it.
Status lineThe last thing that happened. Click it for the activity log.

⌘K (Ctrl+K) jumps to the search box. The buttons at the two ends of the row of tabs show and hide the side panels, and dragging a panel’s edge resizes it.

On macOS the menus are in the menu bar. On Windows they are at the left of the top bar. On Linux and in the browser they are behind the ☰ button. In the browser, a few keys use ⌥ (⌃ on a Mac) in place of ⌘.

The activity log

Click the status line, or press ⌥⌘L, for the log. Problems shows only what went wrong. The copy button puts the whole log on the clipboard for a bug report.

Selecting

Click a row to select it, ⌘-click to add more, ⇧-click to select a run, and double-click to open. Right-click for a menu that acts on everything selected. F2 renames. With more than 64 rows selected, the inspector sums them up: how many, how large, and how many of each kind. A folder or tag selected on its own shows how many files it has.

Reading the Library

The Where column says where a sound lives: on this computer, on the keyboard, or both, with = when the two copies match and ≠ when they differ. It turns red when a send is waiting. A name in italics with a * has unsaved edits. Hover anything for an explanation.

Folders and tags

Folders on this computer are real folders, and the instrument never sees them. Make one with New ▸ New folder. Removing a folder moves what was in it up a level.

Tags label sounds without moving them, and a sound can have several. Tag ▸ New tag… in a row’s menu tags the selected sounds. Click a tag in the inspector to put it on, or take it off, everything selected. Removing a tag deletes no sounds.

MIDI controllers

Instrument ▸ Listen to MIDI controllers lets a controller play the sample or piano in the front tab. Nothing is sent to your instrument. What is supported says which browsers can do this. The browser asks for access on each visit, so turn it on again there. If another program is using the controller, close it and turn listening off and on again.

Theme and zoom

The sun and moon button in the top bar switches between your system’s theme, light and dark. The magnifier at the right of the status line zooms the window.

Your files

This computer is your library: the sounds you open, make, or copy off an instrument. Each one is a real file in a folder you can see.

drawbar is alpha. Keep your own backups of anything that matters. This version starts with an empty library and does not bring over what an earlier version kept.

Where your library lives

On the desktop, your library is a folder in drawbar’s own data:

  • macOS: ~/Library/Application Support/drawbar/library
  • Linux: ~/.local/share/drawbar/library
  • Windows: %LOCALAPPDATA%\drawbar\library

File ▸ Show the library folder opens it. Finder, your backups and Nord Sample Editor see the same files and folders drawbar does. drawbar keeps tags and unsaved edits in a hidden folder beside them, so back up the whole library folder. That folder holds nothing about your other files, so it stays small however large the library is.

In the browser, your library is kept in the browser’s own storage for drawbar.app, where nothing else can see it. Clearing the site’s data deletes it, so export what you want to keep.

Opening another folder

File ▸ Open library folder… opens any folder as your library: a Sample Editor project, a sample pack, a folder on a shared drive. File ▸ Open recent library switches back. drawbar opens the last library again when it starts, and changes nothing in a folder until you change something there. Unsaved edits stay with their library and come back when you open it again.

This works on the desktop and in Chrome and Edge. Firefox and Safari cannot open a folder. Brave can once you turn on brave://flags/#file-system-access-api and relaunch it. In Brave, saving or renaming a large piano takes a little longer.

The first time you open a folder in the browser, drawbar reminds you not to open it in the desktop app at the same time.

In Chrome and Edge, the browser asks whether drawbar may change the folder. Unless you allow it on every visit, it forgets when you close drawbar.app. Choose File ▸ Reconnect and the folder’s name to get it back.

Chrome makes each file and folder drawbar writes there readable only by you, and on macOS marks it as downloaded, so in a shared folder other people lose access to what you save.

Adding and making sounds

Drop files on the window, or use File ▸ Open…. drawbar copies them into your library and leaves the originals where they were. A file drawbar cannot read still shows up, and the activity log says what went wrong.

New makes a fresh sound, note or folder. A fresh Stage program has every control at zero; it is not a factory sound.

Saving

Edits land at once, and the name shows a * until you save. Save (⌘S) writes the file, and queues it for sending if the sound came from a slot on the connected instrument. Revert goes back to the last save. It is the only undo. Unsaved edits are kept when you quit. drawbar also keeps them every few seconds, so after a crash you may lose the last few seconds. In the browser, closing the tab before drawbar has kept your latest edit makes the browser ask whether to leave. Stay a moment, and the edit is kept.

Export… saves a copy somewhere else. Rename with F2. If a name is taken, drawbar asks whether to Overwrite the file there or Keep both.

Bundles

A bundle (.ne5pbundle or .ne5tbundle) is the file Nord Sound Manager uses to carry programs, or a set list, together with the pianos and samples they play. Drop one on the window, or open it with File ▸ Open…, and drawbar unpacks it into a new folder named after the bundle. The bundle itself is not kept.

To make one, check the sounds to bundle and choose Export as bundle…. They can be on this computer, on the instrument, or both. drawbar adds what they need: the programs a set list plays, and the pianos and samples a program plays. Sounds on the instrument are copied to this computer first. In the browser the bundle arrives in your downloads.

A program’s file names its piano and sample by a number only the instrument can put a name to. A piano or sample already on this computer is added when the instrument has named it, which happens when drawbar copies the program from its slot. The activity log lists anything the bundle had to leave out.

Changes made outside drawbar

You can rename, move, edit and delete your files in Finder or any other app. drawbar notices when you come back to its window or tab, and a renamed sound keeps its tags. drawbar reads a tagged file once in the background; a rename before that shows as a new file.

drawbar never writes over a change made outside it without asking. If a file changed while you had unsaved edits to it, drawbar asks what to do:

  • Keep mine keeps your edits. Your next save replaces the file.
  • Take theirs drops your edits and uses the file as it is now.
  • Keep both saves your edits as a new file beside it.

A sound that changed on disk is not sent to the instrument until you have looked at it.

Demo sounds

Start with a demo on the welcome sheet (Help ▸ Welcome) downloads a tine electric piano and a looped pad into a Demo sounds folder. Ask again to bring back any you deleted.

If something goes wrong

  • Read-only. Another copy of drawbar has this library open, in another window or browser tab. Close it. Hover the library’s name for the reason. Changes you make in a read-only library are held in memory only, and the browser asks before you leave a tab that holds some. A browser tab cannot tell that the desktop app, or another browser, has a folder open, so open a folder in only one of them at a time. If both write to it anyway, the one that writes second turns read-only instead of undoing the other’s changes. drawbar also opens a library read-only when its hidden folder has lost the file that lists your unsaved edits, so they are not deleted. On the desktop, put that file back from a backup to get them back. Open without them deletes those edits and opens the library as usual.
  • Damaged index. The file that keeps your tags, where each sound came from and which sounds have unsaved edits cannot be read, so the library opens read-only. Set it aside and open without it renames that file and opens the library without them. The renamed file and the unsaved edits stay in the hidden folder.
  • Missing. The file was deleted outside drawbar, but it had tags or unsaved edits, or came from your keyboard. Save it to bring the file back, or delete it.
  • A write to the instrument left a file. If drawbar stopped, or could not put the old sound back, while it replaced a sound on your keyboard, the sound that was there may now be only on this computer. drawbar offers it when the library opens: Keep in library adds it to your sounds so you can send it back, Show the file shows where it is, and Discard deletes it. A file that does not read is not a copy of the sound, and drawbar offers only to discard it.
  • Differs only by case. Two names in one folder differ only in upper and lower case, such as Pad.ne5p and pad.ne5p. macOS and Windows hold only one of them, so rename one to keep the library whole on every computer.
  • Not read or not all listed. drawbar could not read that file or folder.
  • Failed verification. The sample or piano file is damaged. drawbar will not send it.
  • No storage in the browser. Some private windows give drawbar none. drawbar says so, and what you make lasts only until the tab closes. drawbar also cannot keep a copy of a sound it would replace, so it sends only to empty slots.

How the library works underneath is in Library data model.

Your instrument

Back up your instrument before you send anything. Nord Sound Manager makes a full backup. What is supported says which instruments have been tested.

Connecting

Close Nord Sound Manager first, since it keeps the USB connection to itself. Then click Connect instrument… in the top bar, or choose Instrument ▸ Connect…. A web browser asks you to pick the device. The desktop app takes the first Nord it finds. Install lists the browsers that can connect.

Once connected, drawbar reads the instrument’s folders, and its slots appear, labeled the way the panel shows them: 7:4 Africa Split. Empty slots are listed too, as places to drop things. Instrument ▸ Read everything (⌘R) reads them again. A folder drawbar cannot name keeps the instrument’s name for it and is read only.

Slots

Right-click a slot to Open, Copy to this computer, Load on instrument, Rename, Duplicate or Delete…. Load on instrument makes the panel play that slot, and a document from a slot has the same button in its header. The name is typed in the browser, which opens at the slot when you rename from the Keyboard tab or the Library.

Samples and pianos keep the name they were sent with, so they cannot be renamed or duplicated on the instrument. The instrument also keeps one of each name: a sample or piano named like one already there is queued to replace it, and cannot go to any other slot until you rename it.

With several slots checked, Copy to this computer reads each folder’s slots in one go. Export as bundle… writes them, with what they play, as one bundle; see Bundles.

Drag to move things:

  • a slot onto This computer copies it;
  • a sound onto a slot queues it for sending;
  • a slot onto another slot in the same folder swaps the two.

Sending

Nothing is written until you send. Dropping a sound on a slot, Queue for sending, and saving a sound that came from a slot all add to the queue. Send in the top bar counts what is waiting.

Click Send, or press ⇧⌘S, to review the queue. Click a destination to change it, click × to remove a row, and select a row to see what the slot holds now and what it will hold after sending. Send all writes everything. If the instrument refuses something, the rest stays queued.

When sounds on this computer no longer match the slots they came from, a Queue button appears beside Send. Click it to queue them all.

A sound’s name goes with it, minus the extension: Africa-Split.ne5p becomes Africa-Split on the panel. Sending a sound renamed on this computer renames its slot, even when the slot already holds the same sound.

Safety

  • A move is a swap. Nothing is lost.
  • Before writing over a sound, drawbar copies the old one. If the write fails, it puts the old one back. If that fails too, the old sound is kept on this computer, and the activity log says where.
  • An interrupted transfer cannot leave the instrument stuck on its progress screen.
  • Writing Settings, or the slot the panel is playing, reloads the panel, so unsaved panel changes are lost. drawbar warns you before writing Settings.

Disconnecting

Unplug, and the instrument’s slots disappear. The send queue is kept, so plug back in and send.

Editing

There is no Apply button. A control you move is set on the document at once, and the name shows a * until you save. Nothing on the instrument changes until you queue the document and send it.

The document header

Every document has the same strip across the top: its kind and name, a format badge, where it lives (a slot, a folder, or this computer), its size, and its state: edited, matches keyboard, differs from keyboard, waiting to send or on the keyboard. Hover any of them for the detail.

To the right are the two faces, Revert, Export…, and the document’s main action, usually Queue send. That action is grayed out when there is nowhere to send to, and becomes a warning such as Won't fit · 70 MB over when the send cannot happen yet.

A document that belongs to a slot on the connected instrument also has Load on instrument, which makes the panel play that slot. The panel plays what the instrument holds there, so edits you have not sent are not heard.

The faces

  • Basic is the sound’s controls, in the instrument’s own words.
  • Advanced is the record of the bytes, top to bottom:
    • About this file: what the file says about itself. Set lists, WAVs, files with nothing to edit and bytes drawbar could not read have none.
    • Container: what the file’s header states, and whether its checksum matches.
    • On the instrument, for a document read from a slot: what the instrument reports about that slot.
    • The body. For a program, live slot, settings file or preset this is Every field, a table that includes values the Basic face does not draw. Raw is the value as last saved. Type into the Writes column to set a field. A value the field cannot hold is refused, with the reason. A field changed since the last save is highlighted and marked with a pencil, and the count above the table says how many there are. Samples and pianos list what drawbar can edit in them and where each edit lands in the file. A set list shows its four stored slots, and a file with nothing to edit shows its Body bytes. A WAV and bytes drawbar could not read have no body to show.

Programs, live slots, settings and presets

These open on a panel divided into sections the way the instrument’s is, with a strip of chips at the top to jump between them. Each field is drawn as the control the panel uses: a lamp for a switch, a menu for a selector, a knob with the panel’s own reading, drawbars you pull down, a grid for a pattern. A value drawbar has no name for reads unrecognized value (6) with a warning mark, and stays in its menu so you can change back to it.

Every control answers the keyboard. Tab reaches it, the arrow keys step a knob, Page Up and Page Down jump it, Home and End take it to its stops, and Space flips a lamp. Double-click a knob to type a value.

Where a program refers to a piano or sample by id, drawbar asks the connected instrument for its name and shows it. Sections the program stores but is not using are folded away.

A control with a morph shows three dots. Pick a morph source from the MORPH chips and every morphed control shows that source’s target instead. Editing then writes the morph.

Set lists

A set list opens as the programs it plays, one row each, with its bank and slot, the program’s name where drawbar knows it, and whether the entry resolves. Drag rows to reorder them.

Notes

A text file opens as a box you type in: the set list, the cues, what the desk needs. A .txt, a .md or any other plain text file opens this way, and New ▸ Text note starts an empty one.

A note is saved, reverted, renamed, filed, tagged and exported like anything else on the list. The header counts its lines where another document’s header gives its size. Notes stay on this computer; see What is supported.

Files with nothing to edit

A file drawbar recognizes but cannot yet edit opens with what the container says, and its bytes are under Body bytes on the Advanced face. It can still be sent, copied and tagged, and goes back to the instrument byte for byte as it came off.

Sample instruments and piano libraries have editors of their own. See Samples and Pianos.

Samples

drawbar edits sample instruments (.nsmp) and Nord Sample Editor projects (.nsmpproj) in the same editor. An instrument carries its audio. A project points at WAV files.

The key map

The key map stays at the top. Each zone is a band over the keys it answers, and keys nothing answers are hatched. Drag a band’s edge to move it. The older v2 layout stores only each zone’s top note, so dragging one moves its neighbor too. The v3 and v4 layouts let you pull zones apart and leave keys silent.

Click a key to hear it, or play the keys on a MIDI controller. The line under the keyboard says which zone answered the last key and by how much it was shifted, or why the key is silent. A played key sounds at the velocity you played it, which is the velocity a zone’s window is tested against.

Zones

One row per zone: the keys it answers, its length and channels, and its size. Open a row for its waveform, to edit its root and top note, to play it, or to Save WAV…. Velocity windows are shown but cannot be edited in an instrument.

A v2 instrument also has a gain and a detune for every key. Per key draws them as two lanes you paint across, or as a table for one key at a time.

Projects

A project opens in the same editor with everything editable: both ends of each zone, velocity windows, trims, loops, crossfades, source files and the sound parameters. It is saved back as the text file the Sample Editor reads. drawbar cannot build a project into an instrument yet.

From WAVs

Drop a WAV on the window and its document can Encode it into a one-zone instrument. New ▸ Sample instrument… takes several WAVs, one zone each, with a root key for each. New ▸ Sample Editor project… makes a project from them instead, and the Sample Editor expects the WAVs to stay beside it.

The WAVs must be 16-bit PCM at 44.1 kHz, mono or stereo, in the plain or the extensible WAV format. Convert anything else, such as a 24-bit or 48 kHz file, first.

drawbar can encode v2, v3 and v4 instruments. What is supported says which have been played on an instrument.

Sending

Queue an instrument from its header like anything else.

Pianos

A piano library (.npno) holds one recording, called a stroke, per root note, bank and velocity layer. The editor is for choosing which strokes go to the instrument, so that a library fits the memory it has free, and for setting how it plays.

Edits are a plan

An edit is a plan over the saved library, shown at once, and every switch can be turned back on. The library is rewritten only when you save, export or queue it, and that can take a moment for a large one. Until then the plan is an unsaved edit like any other: the name is starred, and Revert drops it.

The key map

Each root has a cell over the keys it answers. Drag the boundary between two roots to move keys, and the outer ends to cover or uncover keys. Click a key to hear it, or play the keys on a MIDI controller. A key sounds the loudest layer the plan keeps, however hard you play it.

Trim to fit

A list of switches, each with the size it keeps: one per velocity layer, Pedal resonance, Release samples, and Keys, which narrows the range. A meter compares the library with the instrument’s free piano memory and names the cheapest cut that would make it fit. A library that does not fit cannot be queued.

Velocity layers goes finer: one lane per layer, one segment per root. Click a segment to drop that layer for that root only. Each segment gives the velocities its layer plays at with the program’s KBD Touch at 0, on the instrument What is supported names. A higher KBD Touch reaches each softer layer with a softer note.

Playback

Instrument gain, the damper limit (Keys ring on above), and Kind, which is what the instrument files the library under. Kind changes nothing about the sound.

Roots lists each root. Open one to trim its level, hear it, save it as a WAV, or drop it. A 6.x library trims in tenths of a decibel and can also boost a stroke, by up to about 10.8 dB together with the instrument gain. A 5.x library trims in whole decibels and cannot boost. Per key paints a fine tune across the keyboard.

A new library

New ▸ Piano library… takes one WAV per stroke: 16-bit PCM, mono or stereo, at any sample rate. A file named like 060-b0-l00.wav fills in its root (MIDI note 60), bank and layer for you. Otherwise set them in the dialog. Template builds on a library you already have.

A new library is written in piano format 6.x. Advanced ▸ Format offers 5.x for an instrument that does not play 6.x. With a template, the library takes the template’s format. A layer’s value is on its format’s scale: 0 to 135 spreads a root over the velocity range in 6.x, and 0 to 27 in 5.x.

A WAV with a loop, which most sample editors can set, makes a stroke that loops there while the key is held, and the dialog marks it. Audio after the loop’s end is not used, and the loop’s last four samples are replaced by the four just before its start, which a seamless loop already has. While the key is held, the loop fades the way the instrument’s own grand pianos do, faster on higher keys; with Template it fades as the template’s does, or as pianos of the template’s kind do when the template itself never loops. Without a loop, a stroke plays to its end. Release strokes cannot loop.

A release stroke plays at the level the note has fallen to when the key comes up, so a release recorded at full level suits a note let go at once and is heard softer after a long hold. A key let go where its attack recording is silent plays no release.

What is supported says which of these edits have been played on an instrument.

Sending

Queue a library from its header. It is checked against the instrument and its free space first.

Help and About

Help ▸ User guide opens this guide.

Help ▸ What’s new lists what changed in the version you are running. In the browser it opens by itself after an update. On the desktop it opens the release page.

Help ▸ Welcome shows what works today, instrument by instrument, three places to start (connect an instrument, open files, or start with a demo) and the trademark disclaimer. With an instrument attached, the first card names it and opens the Keyboard tab. In the browser it opens by itself the first time you run drawbar there. What is supported covers the same claims in more detail.

Help ▸ Report a problem and Help ▸ Send feedback open a form that sends what you write straight to drawbar’s developer. You choose what to attach, and the form shows exactly what will be sent. Leave an email address if you would like a reply.

In the browser, Help ▸ Share anonymous usage is checked while drawbar sends anonymous counts of visits, errors and instrument operations. Uncheck it to stop them. The desktop app sends no counts. Help ▸ Privacy opens Privacy, which lists every field.

Help ▸ Copy activity log puts the whole log on the clipboard, for a bug report. The activity log has the same button.

Help ▸ About drawbar (on macOS, drawbar ▸ About drawbar) opens the About box, which links to the source, this guide, the releases and the issue tracker.

This build lists what you are running: the version and target, the browser and whether it offers WebUSB when drawbar runs in one, the connected instrument, and how many files this computer holds. Copy diagnostics puts those lines and the activity log’s last 200 entries on the clipboard. Paste them into a bug report so it says what you were running.

Licenses is every license a copy of drawbar has to carry: drawbar’s own, the bundled fonts and icons, the Stage field maps it decodes with, and the Rust crates compiled in. Click a row to read it and to reach the project it covers. The trademark disclaimer is at the foot of the box.

Overview

nord works on Nord files and on a connected instrument from the terminal. It prints data to stdout and everything else to stderr, so its output pipes cleanly. Every command that changes the instrument says what it is about to replace and asks before it goes ahead.

cargo install nord-cli    # or: nix run github:jmoo/drawbar#nord-cli
nord --help

Commands

CommandWorks on
inspectFiles: print what is in them
verifyFiles: check that they re-encode byte for byte
editFiles: change fields in any editable file
bundleNord Sound Manager bundles: made from the instrument, packed and unpacked
deviceThe instrument: what is attached and what it holds
program, setlist, live, settingsPrograms, set lists, live slots and settings on the instrument
sample, pianoSample instruments and piano libraries, on the instrument or as files

program, setlist, sample and piano share the same verbs: get and put to transfer, move, rename, duplicate, delete and select to organize, info, deps, list and focus to look, and edit. live and settings keep only the verbs that make sense for them. The read-only verbs and edit also take a file in place of a slot. The hidden raw --class N reaches the partition at index N, as nord device geometry lists them, for anything without a command of its own.

Slots

Slots are written BANK:SLOT, counted from 1, the way the instrument shows them. 7:4 is bank 7, slot 4.

Output

Color and Unicode appear only on a terminal, and piped output is plain ASCII. --color=always, --color=never and NO_COLOR override the color choice. --yes answers a confirmation in advance. Off a terminal, a command that would ask for confirmation fails instead unless you pass --yes.

Next: Files, The instrument, Editing, and Samples and pianos.

Files

nord inspect patch.ne5p              # what is in a file
nord inspect *.ne5p                  # several at once
nord inspect --raw song.ne5t         # everything, as decoded
nord verify *.ne5p                   # check each re-encodes byte for byte

inspect prints a summary in the instrument’s own terms: the parts and their settings, the effects, and which piano and sample a program depends on. It exits non-zero if any file fails to parse.

verify reads a file, writes it back, and checks that the bytes are identical, reporting the offset of the first difference if not. Every supported format passes, pianos and samples included. nord piano verify --deep also decodes every recording inside, and nord sample verify --deep walks every encoded stroke.

A file where a slot goes

get, info and deps take a file wherever they take a slot, so you can look at a file without an instrument:

nord program get patch.ne5p            # the same summary as for a slot
nord program info patch.ne5p           # format, version, checksum
nord program deps patch.ne5p           # the piano and sample ids it refers to

A file stores ids, not names. deps on a slot asks the instrument for the names. On a file, it prints the ids. The instrument names no dependencies for live slots, so nord live deps prints the ids for a slot too.

Bundles

A bundle is the file Nord Sound Manager carries programs or a set list in, with the pianos and samples they play.

nord bundle get program 7:4 7:5 -o gig.ne5pbundle   # programs and what they play
nord bundle get setlist 1:2 -o b3.ne5tbundle        # a set list, its programs, and what they play
nord bundle unpack gig.ne5pbundle -o gig            # every file at its path, and meta.xml
nord bundle pack gig -o gig.ne5pbundle              # and back

get reads only. It finds each piano and sample by the name the instrument gives it, and warns about one it cannot find in exactly one slot. pack keeps a meta.xml it finds at the top of the folder; without one, it lists which set lists play which programs. Electro 5 bundles only.

inspect and unpack read a bundle only in the uncompressed shape Nord Sound Manager writes. They refuse one with a compressed, damaged or repeated file, or a path that leads outside the folder, and unpack then writes nothing.

Changing what is inside a file is Editing.

The instrument

Back up first. Nord Sound Manager makes a full backup, and it must be closed before nord can connect, since it keeps the USB connection to itself. What is supported says which instruments have been tested.

nord device status                      # what the instrument holds; --json for scripts
nord device info                        # what is attached
nord device geometry                    # its partitions, banks and slots
nord device recover                     # release a session an interrupted run left open

nord program list                       # every occupied slot
nord program focus                      # what the panel has loaded
nord program get 7:4                    # print the summary
nord program get 7:4 -o patch.ne5p      # or save the file
nord program put patch.ne5p 7:4 --yes   # send a file to a slot
nord program move 8:13 7:16 --yes
nord program duplicate 7:2 7:3 --yes
nord program rename 6:13 "Warm Pad" --yes
nord program delete 7:50 7:49 --yes
nord program select 2:12                # load it on the panel
nord program info 7:4                   # size, format, name, checksum
nord program deps 7:4                   # the piano and sample it uses, by name

The same verbs work on setlist, sample and piano. live takes get, info, deps and edit, and settings takes get, info and edit.

Before anything changes

A command that changes the instrument reads the slot first, says what it is about to do, and asks:

$ nord program duplicate 7:2 7:3
duplicating "Africa Split" from bank 7 slot 2 to bank 7 slot 3 — OVERWRITING "Squabble B"
proceed? [y/N]

move reports a swap rather than an overwrite, because the instrument exchanges the two slots and nothing is lost. It also lists the set lists that point at the program, since the instrument updates them to follow it. put names the slot after the file.

--yes skips the question. Without a terminal to ask on, the command stops with refusing to proceed without --yes. There is no --force, and nothing skips the read.

What a write does

The instrument does not overwrite an occupied slot in place, so a put into one deletes the old sound first. nord first saves the old sound in the working directory as a file such as nord-rescued-7-50.ne5p, and puts it back if the write fails. The file is deleted once the slot holds what it should. If the write and the put-back both fail, or nord is stopped partway, the file stays, and put takes it straight back. Live slots and settings are the exception: the instrument overwrites those in place.

While the old sound is read, its file is named nord-rescued-7-50.ne5p.partial. It takes its own name only once it is complete, and the slot is not touched before then. A .partial file left by a stopped nord can be deleted.

--rescue-dir <DIR>, or NORD_RESCUE_DIR, saves the old sound in another folder. Without a folder it can write to, nord leaves the slot alone.

put checks that the file is intact before it touches the instrument.

Every command closes its session even when it fails, so an error cannot leave the instrument stuck on its progress screen. If a run is interrupted, the next command may say the instrument answered out of step. nord device recover releases the session it still holds.

Editing

edit changes fields inside a program, live slot, settings file, set list or sample instrument, in a file or in a slot. Piano libraries have flags of their own, covered in Samples and pianos.

nord program edit --fields                                    # every field, and what it accepts
nord program edit patch.ne5p --set center_panel.gain=96 -o out.ne5p
nord program edit patch.ne5p --set effects_panel.fx1_rate=96 --yes    # in place
nord program edit 7:4 --set center_panel.split=true --dry-run         # on the instrument
nord program edit --set center_panel.gain=64 -o fresh.ne5p            # a new program

Editing a slot reads it, changes it and writes it back, so it asks first. Editing a file in place asks too. -o writes somewhere else instead, and --dry-run shows which fields and bytes would change without writing anything.

Values

Spell a value the way inspect and --fields print it. A value the field cannot hold is refused before anything is written:

$ nord program edit patch.ne5p --set center_panel.gain=200 --dry-run
error: "200" is not a value of gain (accepts 0 .. 127)

Some fields work in pairs. A transpose amount is ignored unless transpose is enabled, so setting one without the other gets a warning.

Live slots and settings

nord live edit 1:2 --set center_panel.gain=96 --yes
nord settings edit 1:1 --set fine_tune=0 --dry-run

Writing settings reloads the program on the panel, and unsaved panel changes are lost. Store them first.

Set lists and samples

A set list’s fields are the slots it plays, slot1 to slot4. A sample instrument’s are its name, where it stores one, and each zone’s root key and top note, plus its low note in the layouts that store one. Instruments from the original Sample Library store no name. Notes are written as names (C4 is middle C) or as numbers, and --fields lists what a given file offers.

nord setlist edit song.ne5t --set slot1=2:5 --set slot4=8:50 -o out.ne5t
nord sample edit inst.nsmp --set name="My Piano" --set zone2.top_note=C4 -o out.nsmp

Any file

nord edit works out the format from the file itself, so it reaches everything the commands above do, plus formats without a command of their own: Stage programs and presets, and Sample Editor projects.

nord edit stage.ns3f --fields
nord edit stage.ns3f --set split_enabled=true -o out.ns3f
nord edit project.nsmpproj --set name=Marimba --set zone129.root_key=C3 --yes

Samples and pianos

Sample instruments (.nsmp) and piano libraries (.npno) hold encoded audio. Both move to and from the instrument with get and put, like a program. The verbs below reach the audio.

Samples

nord sample decode inst.nsmp -o out/                 # every zone as a WAV
nord sample verify --deep inst.nsmp                  # walk every encoded stroke too
nord sample project new --zone a.wav=C3 --zone b.wav=C4 --name Marimba -o marimba.nsmpproj
nord sample build marimba.nsmpproj -o marimba.nsmp   # render a Sample Editor project

encode turns a single WAV into an instrument, and build renders a whole project, loops and stereo included. --generation 2, 3 or 4 picks the layout, and v2 is the default. v3 and v4 also need --unverified, because they have not been played on an instrument.

Both take 16-bit PCM WAVs at 44.1 kHz, mono or stereo, in the plain or the extensible WAV format. Convert anything else first.

Pianos

nord piano inspect grand.npno                      # roots, layers, keys, tuning
nord piano inspect grand.npno --keys               # every key, its root and fine tune
nord piano decode grand.npno --key C4 --layer 0 -o c4.wav
nord piano edit grand.npno --name "My Grand" --tune C4=-2 --map C8=C7 -o out.npno
nord piano trim grand.npno --drop-bank release --layers 3 -o small.npno
nord piano split grand.npno --at C4 -o halves/
nord piano verify --deep grand.npno

edit, trim and split rewrite the library without touching its audio: rename it, retune or reroute a key, drop a bank or the quieter layers, narrow the range, or cut it in two. trim and split never write over their input, and edit does so only with --yes.

Building a piano library

build makes a library from a directory of WAVs, one per stroke, named <root>-b<bank>-l<layer>.wav. 060-b0-l00.wav is MIDI note 60, the attack bank, the loudest layer. Banks are 0 for attack, 1 for pedal resonance and 2 for release. The WAVs must be 16-bit PCM, mono or stereo, in the plain or the extensible WAV format. Any sample rate is accepted.

nord piano build strokes/ --kind mallet --name Marimba -o marimba.npno
nord piano build strokes/ --template grand.npno --name Marimba -o marimba.npno
nord piano rebuild grand.npno -o again.npno        # re-encode a library's own strokes

Every key up to a semitone above the highest root plays the nearest root at or above it. Keys beyond that are silent. Within a root, velocity picks the layer, with the layers spread across the velocity range in order. To set a layer’s velocity value directly, name the file with v12 in place of l00.

build writes piano format 6 unless --format 5 asks for the older format, for an instrument that does not play format 6. A template’s format decides when you give one. Layer values run from 0 to 135 in format 6 and from 0 to 27 in format 5; nord piano inspect names a library’s format.

A WAV with a loop in its smpl chunk, which most sample editors write, makes a stroke that loops there while the key is held. Audio after the loop’s end is not used, and the loop’s last four samples are replaced by the four just before its start, which a seamless loop already has. While the key is held, the loop fades the way the instrument’s own pianos of that --kind do, faster on higher keys. --loop-fade sets the rate at middle C in decibels per second, and 0 holds the loop at its level. A stroke without a loop plays to its end, and a release stroke cannot loop. A release plays at the level the note has fallen to at key-up, and not at all when the attack recording is silent there. A loop is exact in a WAV at 35,002 Hz. At any other rate its ends move to the nearest frame of the resampled audio.

Whatever the audio does not decide, such as decay and per-note tables, comes from --template when you give one. Without a template, --kind, --gain, --damper-top and --loop-fade set the playback values, and the rest uses neutral defaults. Each stroke’s level follows its layer either way.

What is supported says which of these libraries have been played on an instrument.

Building from source

Everything goes through Nix, so a checkout needs nothing else installed.

git clone https://github.com/jmoo/drawbar && cd drawbar
nix run .#drawbar                 # the desktop app
nix run .#drawbar-web             # the browser build, served with this guide beside it
nix run .#nord-cli -- --help      # the nord command
nix build .#site                  # drawbar.app's layout, from this checkout
nix build .#docs                  # this guide

nix run .#drawbar-web serves on http://127.0.0.1:8080/. Set DRAWBAR_PORT for another port. scripts/site.bash assembles the tree drawbar.app deploys: the layout of nix build .#site, with the app taken from the latest drawbar-v* tag.

Checks

nix flake check runs the formatting check, Clippy and rustdoc with warnings denied, and the tests of scripts/bump.bash. nix build .#nord.all builds every crate with its tests, the cross targets, the browser build and this guide. CI runs both. nix build .#<crate> builds and tests one crate. nix fmt formats everything. Testing describes the suites, and Contributing the house rules.

Generated files

Two files are generated by scripts, which run in the nix develop shell. The drawbar suite fails until the matching script is re-run.

  • scripts/licenses.bash regenerates the license notices drawbar shows for its Rust crates. Re-run it when the registry packages drawbar reaches in Cargo.lock change.
  • scripts/glyphs.py regenerates drawbar-glyphs.ttf, which draws the characters drawbar’s text uses that no other bundled font has. Change a glyph in the script’s table and re-run it, or add one when the text uses a character no face draws.

Build by hand

Cargo needs stable Rust, and on Linux the ALSA headers and pkg-config. nix develop opens a shell with all of it. Run Cargo from crates/, because crates/.cargo/config.toml carries a flag the wasm build needs, and Cargo finds it by walking up from the working directory.

cd crates
cargo run -p drawbar
cargo run -p nord-cli -- --help
cargo test --workspace

The browser build

The browser build also needs the wasm32-unknown-unknown target and wasm-bindgen-cli at the wasm-bindgen version in Cargo.lock. nix build .#drawbar-web does all of this in one step. By hand, from crates/:

cargo build -p drawbar --lib --target wasm32-unknown-unknown --release
wasm-bindgen --target web --out-dir drawbar/pkg \
  target/wasm32-unknown-unknown/release/drawbar.wasm
cd drawbar && python3 -m http.server 8000

--lib is required. The crate also has a binary target of the same name, and if that one wins, the wasm module exports nothing. nix run --inputs-from .. nixpkgs#wasm-bindgen-cli -- in place of wasm-bindgen takes the matching version from the flake’s pinned nixpkgs.

Serve over http://localhost, which counts as a secure context. WebUSB, the module import and the library’s storage all fail from file://. The page loads library-writer.js, the worker that writes the library, from beside itself; serving crates/drawbar serves it too. This plain server has no guide beside the app, so Help ▸ User guide finds nothing there. nix run .#drawbar-web serves both.

nix build .#drawbar-web also writes the version and the module’s byte count into the page. Built by hand it has neither, so the loading screen shows no version and measures the download against the server’s Content-Length, or not at all when the server compresses it.

Checks by hand

The checks need taplo, shfmt, shellcheck and nixfmt beside the Rust toolchain, with rustfmt and Clippy. CI uses the versions Nix pins, so a different version can still disagree. A pull request labeled autofix gets CI’s formatting committed to it.

Format from the repository root. The file lists come from Git, so git add new files first.

cargo fmt --manifest-path crates/Cargo.toml --all
git ls-files -z '*.toml' | xargs -0 taplo format --option array_auto_collapse=false
git ls-files -z '*.sh' '*.bash' | xargs -0 shfmt -w -i 2 -s
git ls-files -z '*.sh' '*.bash' | xargs -0 shellcheck --external-sources
git ls-files -z '*.nix' | xargs -0 nixfmt

Lint and test from crates/. The features are each crate’s testFeatures, and Clippy adds corpus so the corpus suites compile.

cargo clippy --locked --workspace --all-targets --features nord-format/bundle,nord-format/corpus,nord-usb/blocking,nord-usb/replay,nord-usb/corpus -- --deny warnings
RUSTDOCFLAGS="--deny warnings" cargo doc --locked --workspace --no-deps
cargo test --locked --workspace --features nord-format/bundle,nord-usb/blocking,nord-usb/replay

File formats

nord-format reads and writes Nord files. It is a pure library: no USB, no I/O beyond Read, Seek and Write, and it builds for the browser.

Round trips

Whatever the library reads, it writes back byte for byte. Regions it does not decode are kept as raw bytes, and decoded values are views over them, so a file survives a round trip even where its meaning is not fully known. Every fixture and every file in the private corpus is checked this way, and every editable field is set and read back to prove it moves no other bit.

Large libraries

A piano library or sample instrument can run to hundreds of megabytes, and a whole read holds all of it in memory. npno::Index and nsmp::Index read only a file’s headers and stroke directory, through Read and Seek, and give the byte range of each stroke’s audio. A caller reads one stroke by its range and decodes it. A sample stroke decodes against the file’s peak, the largest statistic B of any of its strokes, which nsmp::Index::file_peak gives. The index does not verify the container checksum, which covers every byte; cbin::inspect checks it in one streaming pass, and cbin::Verifier does the same over chunks a caller supplies. Both report the body’s CRC-32, the number an instrument reports for the slot holding it, whichever checksum the file stores. Verifier::seal gives the checksum such chunks call for, for a writer that streams a body before its checksum is known.

An edit is saved without a whole read too. A sample instrument’s nsmp::Outline, from its index, answers what a whole read answers outside the audio and takes the same edits; Index::patch turns it into a cbin::Patch, the few sections the edit changed and the restated checksum, which Patch::copy writes while copying the file through. An edited piano library is written by Library::write_from, which reads one stroke’s audio at a time from the source file; npno::Index::still_matches first says whether the file still holds the directory the index read.

The support map

The authoritative list is in the formats module documentation on docs.rs, beside the code. Each decoded body’s documentation says how far reading and writing go, what a read validates, where the field placements came from, and shows the generated byte map. There are three tiers:

  • Decoded: every field named. Electro 5 programs, live slots, set lists and settings; Stage 2, 3 and 4 programs and live slots; the Stage 3 synth preset; the Stage 4 synth, piano and organ presets.
  • Structurally decoded: the container and layout are understood, and the audio is decoded and encoded. Sample instruments in every layout, piano libraries in both formats (5.x and 6.x), and Sample Editor projects.
  • Container-verified: recognized, checksummed and kept byte for byte. Every other CBIN tag, the Lead SysEx banks, the .cn3 library, and ZIP backup bundles behind the bundle feature.

Nord Sound Manager’s bundles are a stored ZIP and a meta.xml manifest. nord_format::bundle reads and writes both a member at a time, never holding the archive, and refuses any archive laid out otherwise, so a bundle it reads writes back byte for byte. It needs no feature.

Text notes are outside these tiers, because nord-format does not read them. drawbar treats a file it cannot decode as a note when its bytes are UTF-8 text of up to 256 KiB. In a note, Tab types a tab, and pasted control characters other than tab and line breaks are dropped. A file that begins with the magic of a format nord-format decodes, or whose extension names one, is never a note, so one that fails to decode keeps its error. An empty file is a note only when it is a .txt. See Editing.

Both generations of the CBIN container are read and written: the current one with a CRC-32 over the body, and the older one with a CRC-16 over the whole file.

The field registry

Every #[bitbody] layout generates a registry. fields() lists each field with its path, placement, current value and accepted values, and set_field(path, value) writes one back through the type’s own parser. That is what nord edit --fields prints and what drawbar’s Advanced table shows, so a field becomes editable by being declared.

Each field carries its control kind: a knob with a unit, a bipolar knob, a selector, a drawbar, a morph slot, a pattern grid, or a library reference. panel::of adds the layout for formats that have one: which controls sit together, in what order, and which the instrument is using for the state the file holds.

#![allow(unused)]
fn main() {
let entity = nord_format::from_path("patch.ne5p")?;
if let Some(panel) = nord_format::panel::of(&entity) {
    // ordered, nestable groups, each with a condition over the body's values
}
}

Features

bundle reads the members of ZIP archives as entities, backups and Drum banks among them, and is off by default. corpus is for tests only and points the sweep at the private corpus. See Testing.

To build the API documentation locally:

cd crates && nix develop -c cargo doc --no-deps --open

Library data model

drawbar keeps This computer as a folder of real files. This page describes how the app models that folder, how it reads and writes it, and how it stays consistent with changes made outside drawbar. What a user sees of it is on Your files.

The code is in crates/drawbar/src: store/ holds the library on disk, workspace.rs the assets in memory, folders.rs the folder tree, ondisk.rs the files left on disk and read by range, and summary.rs with store/cache.rs what drawbar remembers of a read between sessions.

The library on disk

A library is a folder tree. A folder in the browser is a directory, an asset is a file, and a move is a rename. Any folder can be opened as a library.

  • Desktop: the default library is library in the folder eframe keeps the app’s data in (eframe::storage_dir): ~/Library/Application Support/drawbar on macOS, $XDG_DATA_HOME/drawbar or ~/.local/share/drawbar on Linux. On Windows that folder is in the roaming app data, which a domain profile copies at every sign-in, so the library is %LOCALAPPDATA%\drawbar\library instead, falling back to the roaming folder when LOCALAPPDATA is unset (store::native::default_root).
  • Browser: the default library is the root of the origin private file system (OPFS). In browsers with showDirectoryPicker, the user can also pick a folder on the computer (store::web::Root).

What a file cannot say lives in a hidden .drawbar/ folder at the root:

flowchart LR
    root["&lt;root&gt;/"]
    root --> sidecar[".drawbar/"]
    root --> grand["Grand.npno"]
    root --> cello["Cello/"]
    cello --> c3["c3.wav"]
    cello --> temp[".c3.wav.drawbar-tmp<br/><i>a desktop save in flight</i>"]
    sidecar --> index["library.ron<br/><i>the index</i>"]
    sidecar --> lock["lock<br/><i>held by the one writer</i>"]
    sidecar --> tmp["tmp/<br/><i>writes in flight</i>"]
    sidecar --> working["working/"]
    working --> copy["7-12<br/><i>asset 7, generation 12</i>"]

.drawbar/ is made at the first write, never on open, so a folder opened and looked at stays as it was. The default library’s folder is also made at its first write.

The index

.drawbar/library.ron is one RON file, rewritten whole, so writing it is a single commit point. It is written in id order, so one library always serializes to the same text (store/sidecar.rs).

The index holds a row only for an asset with something its file cannot say: tags, an unsaved edit, or the slot it came off. It also holds one for a file of a kind drawbar does not open but holds because it made the file or was given it. Every other file is known from the listing alone, so an untouched library’s index is a few lines, and it grows with tags and edits, not with the number of files. A row that stops holding any of these is dropped at the next write. A field with nothing in it is not written. Abridged:

(
    version: 1,
    next_id: 9,
    next_generation: 13,
    tags: {
        1: "Gig",
    },
    assets: {
        7: (
            path: Some("Cello/c3.wav"),
            fingerprint: Some((
                len: 88244,
                modified: Some(1727712000123456789),
                crc: Some(3735928559),
            )),
            tags: [1],
            working: Some((
                generation: 12,
                keeps: Bytes,
            )),
        ),
    },
)
FieldMeaning
versionThe index version. This build writes 1.
next_idThe id the next new asset takes.
next_generationThe generation the next working copy is written under.
tagsTag names, by tag id.
assetsOne row per asset that holds something its file cannot say, by asset id.
pathWhere the asset’s file is, /-joined from the root. None for a view of a slot kept only as a working copy.
nameThe asset’s name, written only for a row with no path.
fingerprintLength, modification time in nanoseconds, and CRC-32 of the file when drawbar last read or wrote it. The CRC is None until something has read the whole file.
tagsTag ids.
originWritten only where the path does not say it: Device(class, bank, slot), Rescued(bank, slot), Fresh for a row with a path made with New, or File(name) for a row with no path. A row with a path and no origin came from its file; one with neither is fresh.
workingThe asset’s working copy, while it holds an unsaved edit: its generation, and what it keeps, Bytes or Edit.

A path read from the index is checked on the way in: every component must be a real name, never empty, . or .., and never holding /, \ or NUL (store::LibPath). The index is a file anyone can edit, and a .. would reach outside the library. A link would too, so on the desktop every folder a path passes through below the root is looked at first, and one that is a link refuses the operation as not found: an index row through one reads as missing. A folder made a link between that look and the operation is still followed. In the browser no handle reaches through a link: the private file system holds none, and Chromium hides a link at any depth below a picked folder.

The version is read first, on its own. An index with a higher version opens the library read-only and is never rewritten, so a newer drawbar’s index survives an older one being run over it. So does an index of this version that holds a field, or a kind of origin or working copy, this build does not know: rewriting it would drop what a newer build added. An index that does not parse also opens the library read-only, and can be set aside (see Opening, the lock and read-only).

Working copies, tmp/ and lock

  • working/<id>-<generation> holds an unsaved edit. An edit whose bytes differ from the copy’s gets a new generation, and the old file is deleted once the index stops naming it; one the copy already holds, as at every launch, writes none. A copy is rewritten only by a save of a later edit, just before the save lands (see The order of a pass), and moved into place by a save of exactly what it holds. One that keeps: Bytes holds the asset’s bytes whole. One that keeps: Edit holds an edit of a piano library or sample instrument resting in its file, as RON text under a version of its own (rewrite::Edit::working):

    (
        version: 1,
        edit: Sample([
            ("name", "Vibes"),
        ]),
    )
    

    A sample’s edit is the path = value sets made since it was saved, and a piano’s is its plan (document::piano::Plan), the format’s banks and kinds written by their codes. A copy of another version does not read, and opens the library read-only.

  • tmp/ holds the temporary files of writes in flight. On the desktop that is only the index’s own files; a library file is staged as a hidden sibling, .<name>.drawbar-tmp, so its rename never crosses a volume. A temporary is made only where no entry is, after removing a stale one, so a link left at its name is never written through; a name taken again gives way to .<name>.<n>.drawbar-tmp. In the browser every write is staged in tmp/. It also holds a slot’s occupant while a write to the instrument replaces it, as nord-rescued-…, which the open’s sweep leaves and offers once the listing is complete, unless it is still named .partial.

  • lock is held by the one drawbar that may write the library, so a second drawbar opens it read-only.

The in-memory model

The workspace and its assets

Workspace (workspace.rs) is the model the rest of the app edits. It holds every asset as a LocalEntity. The fields that matter here:

FieldMeaning
idNames the asset to the index, the selection and the send queue. See Ids.
nameIts name. While path is set, it is the path’s last component.
pathWhere its file is. None for a view, and for a kept asset not yet placed.
originWhere it came from: a file, a slot, the New menu, or a rescue.
bytesIts current bytes, a shared Bytes. Empty while it is unread or rests in its file.
entityWhat the bytes decode to, boxed, or None before the decode or where it failed.
savedIts Baseline: what it was last saved as.
keptWhether it is on this computer, as opposed to a view of a slot.
pendingWhether an editor holds an edit not yet applied to bytes.
stampDistinct for every set of bytes this id has held. Bytes back to the baseline’s take its stamp, so the baseline does not move and nothing is written.
rememberedWhile it is unread, the Summary a read of its file found before. See The derived cache.

Bytes wraps an Arc<[u8]>. The baseline shares the allocation while the bytes are the same, so a clean asset is held once; an edit puts new bytes in place and never writes through the shared allocation.

A Baseline is what the asset was last saved as:

FieldMeaning
bytesThe saved bytes, or empty where file or unread holds them.
crc32The checksum a slot holding these bytes would report: the CRC-32 of the body without its container. device::link and library::agrees decide on it.
stampThe stamp of these bytes. The asset is unsaved when stamp differs from its own, or pending is set.
fileThe file holding these bytes, left on disk and read by range (ondisk::OnDisk).
unreadThe file’s length, while nothing has read it.

LocalEntity::is_unsaved compares stamps, never bodies. Every listed row asks it every frame, and a piano library is hundreds of megabytes.

A send that lands moves the baseline to what it sent (Workspace::landed). An asset sent from the file it rests in keeps that file as its baseline, and Workspace::landed_file sets the link and the write from the checksum its check took; nothing reads the file again. An asset resting in another file by the time the send lands, as one whose edit was saved during the send does, keeps resting there, and its write records the checksum of what the slot took, so the slot shows as behind. An asset that holds its bytes in memory by then takes the sent file as its baseline again under a stamp of its own, so an edit made meanwhile shows as unsaved.

Ids

The workspace hands out ids from next_id. A row keeps its id from session to session; a file with no row takes a new id each time the library is listed, since nothing refers to it. next_id is kept in the index so a row’s id is never given to another asset. When a library opens, every row whose id is below the workspace’s next_id takes the next free id instead (Store::begin). That happens when a view of a slot was opened first, or when another library was open before in the same window: its views stay, and their ids must not reach an asset of the new library. A renumbered row’s working copy is written again under the new id at the next full pass, and the old one is dropped.

Views and kept assets

A view is a working copy of a slot on the instrument. It is not listed, has no file, and goes when its tab closes. Workspace::keep promotes one into the list, and closing the tab of a view that holds an edit, or that waits in the send queue, promotes it instead of dropping it.

A view that holds an edit is still kept in the index, as a row with no path and a working copy. When the library next opens, that row comes back as a file on this computer, placed in the root, and its working copy stays until the file is written. When another library takes the window, the views stay in the window, so their working copies leave the library being closed (Store::hand_over).

An asset’s lifecycle

stateDiagram-v2
    state "Not read" as NotRead
    [*] --> Listed: listed
    Listed --> Remembered: a fresh cache entry
    Listed --> Reading: needed
    Remembered --> Reading: picked, opened or acted on
    Remembered --> Listed: file changed
    Reading --> Whole: read whole
    Reading --> Resting: piano or sample
    Reading --> NotRead: gone, error or no room
    NotRead --> Reading: room made
    Resting --> Unsaved: edited
    Whole --> Unsaved: edited
    Whole --> Remembered: evicted
    Unsaved --> Whole: reverted
    Unsaved --> Saving: saved
    Saving --> Whole: written
    Saving --> Unsaved: refused
    Listed --> Missing: file gone
    Whole --> Missing: file gone
    Unsaved --> Missing: file gone
    Missing --> Saving: saved back
  • Listed. The asset holds its file’s name, length and time, and nothing else. Its baseline’s unread is set, its verify state is Reading, and its kind comes from its extension.
  • Remembered. Still unread, but the cache holds a summary of an earlier read of the file, taken at the length and time it has now. It draws its kind, tag, slot checksum, the library it plays and its verdict from that summary, and its verify state is Remembered. A row in view does not read it.
  • Reading. Something needs it, or the index tracks it and it is read in the background. A Cmd::Read for its file is in flight.
  • Whole. Its bytes are in memory. They are decoded off the frame, up to eight jobs of 64 files or 16 MiB at once on the desktop, and one job of 16 files or 2 MiB a frame in the browser. An act about to use the decode runs it at once (Workspace::read_now). Decoding re-encodes and compares, as for any file that arrives.
  • Resting. A piano library or sample instrument stays in its file, indexed. Its checksum is checked off the frame, one file at a time, and until then it is Checking. A file that fails its check is not sent. On the desktop each resting file holds a file handle open, so the desktop raises its open-file limit at start (ondisk::raise_open_files). In the browser it holds the File snapshot the page took of it.
  • Unsaved. Its stamp differs from its baseline’s. At the next full pass its bytes are written to working/ under a new generation, unless its working copy holds them already. An asset whose file is missing counts as unsaved as soon as it is restored, so its copy is kept as it was. An edit of a resting asset stays an edit held over its file, and the asset stays resting until the save writes the file again; its working copy keeps the edit, not the bytes.
  • Saving. Saving moves the baseline to the current bytes, and the store writes the baseline to the file. A baseline that rests in its file needs no write. A save refused because the file changed, one that failed, and one sent as the library turned read-only all leave the asset unsaved again.
  • Missing. Its file went missing while it held something precious: tags, an unsaved edit, the slot it came off, or a place in the send queue. Nothing writes its file again until it is saved, and that save creates the file anew. A file deleted outside drawbar that holds nothing precious leaves the list.
  • Not read. The library could not read it. It stays unread and is not asked for again, except for a read refused for room, which is retried once room can be made.

A clean asset that is evicted goes back to unread under a new stamp, and keeps its baseline’s crc32 and a summary of what it held, so it is Remembered: it still draws as it did and matches its slot. A change on disk to an unread file drops that checksum and the summary, since they no longer stand for the file, and it is Listed again.

Listing and reading

The metadata walk

Opening a library lists every file by its name, length and time, and reads nothing but the files under working copies (store/exec.rs).

  1. The index’s rows are looked at first, where the index says they are, in parts of 256, and sent before the walk begins. A row’s file that is gone lends its length to a set the walk checks for moved files.
  2. The tree is walked breadth first, one folder at a time, so the top of a large tree arrives first. A file a row already found is not looked at again. Entries are sent in Event::Listed parts of 256, and the app folds in four parts a frame.
  3. Anything under a name that starts with a dot is left out of the listing, and a dot folder is not entered. The .drawbar-tmp siblings of interrupted saves are gathered on the way, to be swept.
  4. A listing looks at no more than 1,000,000 entries, counting files, folders and entries left out (exec::MOST_ENTRIES). A folder past that, or one that cannot be read, is reported as not all listed.

A file’s kind is decided by its extension alone: store::opens matches it, ignoring case, against the formats nord-format names by extension (formats::by_extension) and drawbar’s notes (browser::tagged). Any other file is listed by name only, as one of the folder’s others, and shown with Show all files.

Lazy reads

A listed asset is read once something needs it. The workspace keeps each one wanted with how much it is needed, and the store keeps one Cmd::Read in flight: when it answers, the next goes out with the most needed of what is wanted then, at most 32 files or 8 MiB. Nothing is asked until the cache has said what it remembers. In order of need, these ask:

  • an act that works from an asset’s contents, which waits until each asset in Act::reads has been read (browser/act.rs), and the app, every frame, for what is open in a tab (Workspace::hurry);
  • the app, once each time the selection changes, for the first 64 assets selected (Workspace::select). Each stays wanted until it is read, for as long as it stays selected. An act on the rest reads what it needs;
  • Workspace::in_view, for the rows the library table draws. A row draws what is remembered of it, so only a row with nothing remembered is read, and a row not drawn this frame or the last is no longer read for it.

An asset needed this frame or the last, or selected, is not evicted.

Background reads

Once the listing is complete and the cache has answered, and again at each full pass, the store reads the unread files the index tracks, whole: those with tags, a working copy or a slot they came off (Store::fetch_tracked). Reading them decodes them and takes their slot checksum, so they match their slots on the instrument before anything shows them. A remembered file is skipped, since its summary already carries that checksum. They go 32 files or 16 MiB at a time, and a read the user waits on runs after the batch in flight. A tracked file whose CRC the index does not know, and that no background read will cover, has its CRC taken alone, 64 files or 64 MiB at a time (Cmd::Fingerprint), so that an outside rename keeps its row. Neither starts a batch while a read something needs is wanted or in flight.

The memory budget

The assets may hold at most 1 GiB of a library’s files whole (exec::MOST_BYTES). The count is every asset’s bytes, plus its baseline’s where they are not the same allocation, plus the listed length of every read in flight. A file resting in its file takes none of it.

Each Cmd::Read carries the room left. The backend reads its files in order, and answers each one that would not fit in what is left with Failure::Room. The store then makes room by evicting clean assets, the least recently needed first, and those the index tracks only after the rest (Store::make_room). It evicts nothing unless that makes the room. A background read evicts only untracked assets.

These are never evicted:

  • a view, an unsaved asset, or one with a pending edit;
  • one unread already, or resting in its file;
  • one needed this frame or the last, or selected;
  • one in the send queue;
  • one with a save in flight, a working copy, or a missing file;
  • one whose path is under a rename not yet answered.

A read that cannot fit even then marks the asset not read, and it is retried, smallest first, once room can be made.

Reading by range

OnDisk::open indexes a CBIN file whose tag is npno or nsmp with nord_format::formats::npno::Index or nsmp::Index. Those read only the container header, the prefix and the stroke or zone directory, and give the byte range of each stroke’s audio. A piano document reads one stroke by its range (OnDisk::read). A file whose index does not read is read whole, so its decode can say why.

The index does not verify the container checksum. The resting check does, in one streaming pass that also takes the whole file’s CRC-32 for its fingerprint (OnDisk::verify). Until it has, a resting file is not known to hold any bytes (OnDisk::holds), and nothing takes its CRC on the frame. A file whose stat moved is told to hold what drawbar knew by its CRC, taken in one streaming pass (Fs::crc). A save, a copy over a file and a rewrite look at the file again once the new contents are staged, just before the rename, and refuse one whose stat moved (exec::put); a window of one stat and one rename remains, since no portable rename checks what it replaces. Where the browser copies instead of moving, the copy looks again just before it lands. A resting file changed on disk under an unsaved edit is not read whole to keep the edit apart: the asset rests in the new file, and the workspace makes the edit again over it (rewrite::Edit::over). An edit the new file already holds is let go. One that no longer applies is kept as it was, still unsaved, and refuses to be saved, sent or copied until it is edited again or reverted. On the desktop the handle follows the file through a rename, and reads whatever the file holds now if it is rewritten in place; a rescan notices that and indexes it again.

In the browser a read answers only later. The indexes read through a reader over the slices fetched so far (ondisk::Slices), which reports the file’s real length and fails naming the range it lacks; the backend fetches that range, at least 64 KiB of it, and reads the index again. A piano’s index arrives in one fetch after the 12 bytes that say what the file is, and a sample’s in about one fetch per stroke. A read on the frame of a stroke not fetched yet answers WouldBlock and fetches it, and the repaint when it lands plays it. A pass over the whole file, the checksum or a whole read, streams 4 MiB slices in a task of its own. A File snapshot fails to read once its file is written, so a move this tab makes takes the snapshot again where the file went, and a file written over is indexed again.

A file from outside, dropped or picked with File ▸ Open…, is copied into the library by Cmd::Import and never held (Act::Take). Its asset waits unread until the copy lands (Workspace::arrive), then rests where it is a piano or sample instrument and is read on demand otherwise. A name already taken asks, as a rename does: Overwrite copies over the file there (Act::TakeOver), with the same check a save makes, and Keep both copies under a free name. A copy whose name something took first is placed again, and one that fails, as at a library that turns read-only, is read into memory instead. On the desktop the copy streams into a hidden sibling and is linked into place; in the browser the page slices the File and hands each slice to the writer, or to the picked folder’s writable stream, without passing it through the tab’s memory. The browser catches a drop on its way to the canvas (dropped.rs), since eframe reads a dropped file whole, and lands it in the folder whose tree row it fell on. The desktop windowing reports no drop point, so there a drop lands at the top level unless the pointer moved over the window while the files hovered.

A sample’s document draws from the index’s outline, and an open zone reads its own stroke’s range. An edit of it is held over the file as the sets made since it was saved, and the outline they make (document::sample::Edits). A piano plan over a resting library is checked against the index’s stroke directory, which carries each stroke’s range. Either way the workspace holds the edit, as the sets or the plan (rewrite::Edit), and the rewrite that saves it into the file the asset rests in (Workspace::hold_edit, rewrite.rs). An editor takes up the workspace’s edit wherever the asset rests in a file its own edit was not made over. A save sends Cmd::Rewrite: the backend writes the file again from itself into a temporary, then puts it over the file, and the asset rests in what it wrote. A sample’s copy splices the edited sections in and restates the checksum (cbin::Patch::copy); a piano’s is laid out by npno::Library::write_from, which reads one kept stroke at a time by its range. In the browser the copy is laid out first as the ranges of the file it keeps and the bytes the edit holds, recording a piano’s layout with a writer that keeps each stroke’s range rather than its audio, and each range is sliced from the File and handed to the writer; a piano’s checksum is taken as the copy streams (cbin::Verifier::seal) and written last. A sample’s copy checks its checksum as it streams, so a file changed since its index was read is refused. A piano’s copy reads the file’s prefix and stroke directory again first (npno::Index::still_matches), and refuses one that is not the directory its index read; its audio is not compared. Either kind is refused where the file’s stat moved while the copy was written. Nothing is put over the file then. In the browser a file written since its File was taken fails to read at all. An export or send of an asset holding such an edit waits for it to be saved first. An edit held this way is kept across a quit as a working copy of the edit itself, and the next open makes it again over the file.

Duplicate, Keep both, and an Overwrite that puts a resting file over another, are copies the library makes too (Workspace::arrive, store::CopyOf): Cmd::Import copies the library’s own file byte for byte (on the desktop a clone where the disk can make one and a streamed copy otherwise, and slice by slice through the writer in the browser), or writes the edit held of it through as it copies. The copy’s asset is unread until it lands, then rests in it. A file a copy is still to be made of is not deleted until the copy answers, and the asset an Overwrite moved over another file goes only once its copy has landed: one that fails leaves it. Exporting a resting file copies it across without reading it into memory.

A send never reads a resting file whole either. The command carries the file (device::Payload::File), and the worker reads it one transfer chunk at a time through nord_usb::FileSource: by position through the handle on the desktop, and by File.slice through the snapshot in the browser. A file that fails to read partway through a send fails it as a refused write does, and the slot’s occupant is put back.

Nor is an occupant held whole. Before a write replaces one, the worker reads it back through op::read_into into a file, a transfer chunk at a time, so it can put it back through write_from if the write fails (worker::put). The file is written under a .partial name, closed, checked against the length and checksum it was read with (op::verify_read), and only then renamed to its own name, with its folder synced, all before the delete. A process that dies with the slot empty leaves the whole occupant on disk, and one that dies during the read leaves only a .partial file and the slot as it was. A power cut is covered only where the folder sync is: on macOS and Linux, not on Windows, and not in the browser, which offers no sync at all. The file (device::scratch) is in the library’s .drawbar/tmp/ on the desktop while the library may be written, made where missing, or a rescued folder of drawbar’s own data (eframe::storage_dir) otherwise, never the system’s temporary folder, and in .drawbar/tmp/ of the private storage in the browser, through a library-writer.js of the device’s own. The restore runs in a session of its own once the failed write’s session has closed (worker::put_back): the instrument drops a write left unfinished when its session closes, but keeps it as an object in another slot when a second write follows it in the same session. The file is deleted once the slot holds what it should. Where the restore fails as well, or a delete may have landed, it stays, DeviceEvent::Kept names it in the log, and the next open offers it. It is the only copy drawbar keeps. The file is named as its rescue, nord-rescued-<bank>-<slot>.<tag>, numbered where that name is taken, and the open’s sweep of tmp/ leaves those names, since one left by an interrupted write is the slot’s only copy. It removes the .partial ones, which never are, as the open does in the rescued folder.

Once an open’s listing is complete, in a library that may be written, each file named that way in its tmp/ and, on the desktop, in the rescued folder is offered in a question (Browser::ask_rescue). Each is read through first and checked against its stored checksum (envelope::verify). One that does not read is said to be no copy of the slot, and offered only to be discarded. Keep in library renames one in tmp/ into the library’s top level under its own name, or a free one, and rescans; one in rescued is copied in as a file from outside and deleted once the copy lands. Show the file opens its folder and asks again. Discard asks once more, then deletes it. Later leaves it to be offered at the next open.

The queue compares a resting file with a slot’s occupant by the checksum its check took, so its diff says only whether the bodies differ. The instrument reports no checksum for a piano or sample slot. One whose body length, which it does report, differs from the asset’s is different without a read. One of the same length is read through op::read_into into std::io::sink() (DeviceEvent::Summed), and its CRC-32 decides.

The store protocol

Store (store/mirror.rs) is the app’s side of a library. It mirrors the workspace onto the files and folds what it hears back into it. A backend runs what it is told against the files. They talk in Cmds and Events (store/mod.rs), and the app never waits for an answer: the desktop backend runs on a thread of its own, and the browser’s storage answers only asynchronously.

CommandAnswered by
OpenOpened, then Listed parts, then Complete. Only Opened(Err) if it failed.
ScanScanned: the whole tree again, rereading only held files whose stat moved.
CheckChecked: the named files again, without listing the tree.
Walk(dir)Walked, once dir is listed whole ahead of the rest of an open.
ReadRead: each file, held whole or resting, or why not.
FingerprintFingerprinted: the CRCs of files whose stat has not moved.
SaveSaved: the new fingerprint, or why not.
RewriteRewritten: a resting file written again with an edit, found as a listing finds it, or why not.
ImportImported: a copy of a file from outside or of the library’s own, found as a listing finds it, or why not.
MoveMoved: whether the rename happened.
CommitCommitted: whether the working copies and the index were written, or the step that failed, its file and why (Unkept). A failure at the same step for the same cause is logged once, though each retry names a new working copy, until a commit lands. ReadOnly where another drawbar changed the index.
MakeDir, RemoveFile, RemoveDir, DropUnindexed, SetAsideOnly Failed, on failure.

Every command that writes first makes .drawbar/ and takes the lock. Where it cannot, it runs no further and is answered by Event::ReadOnly.

Both backends implement one async trait, exec::Fs: list a folder, stat, read, create, replace, rename, make and remove. exec::run executes every command against it, so the protocol’s behavior is one piece of code. The desktop’s calls finish before they return, and exec::execute blocks on them on the backend’s thread (store/native.rs). The browser runs them in a task of its own on the page’s thread (store/web.rs).

Ordering

Commands run in the order sent, each after the one before. An open’s listing can take a long time, so the commands sent while it is in flight run between two of its folders, and the rest of the listing follows what they did:

  • A rename moves the folders still to be listed, and a file it moved is not listed again at its new path.
  • A file a save wrote is not listed again.
  • A folder made or removed is added to or dropped from the walk.
  • A Walk(dir) moves dir and what is in it to the front of the queue. A folder’s removal waits on it, since the folder must be listed whole before the app knows it is empty.

Each Listed part carries ran: how many commands sent since the open had run when it was gathered. A part gathered before a rename names what the rename moved by its old path. The store counts the commands it sends, so it can bring such a path to where it is now. It does so only once the rename has answered Moved with success; until then, every part gathered before it is held back, in order (Loading::waits). A rename that failed leaves the paths where they were.

sequenceDiagram
    participant Store
    participant Backend
    participant Disk
    Store->>Backend: Open
    Backend->>Disk: lock, read the index and working copies, sweep
    Backend-->>Store: Opened
    Backend-->>Store: Listed (the index's rows, ran 0)
    Note over Store: the user renames Kits to Drums
    Store->>Backend: Move Kits to Drums (command 1)
    Backend->>Disk: list one folder
    Backend-->>Store: Listed (Kits/a.ne5p, ran 0)
    Note over Store: held: gathered before a rename not yet answered
    Backend->>Disk: rename Kits to Drums
    Backend-->>Store: Moved, ok
    Note over Store: release the held part, as Drums/a.ne5p
    Backend->>Disk: list Drums/Low, where Kits/Low was queued
    Backend-->>Store: Listed (Drums/Low/b.ne5p, ran 1)
    Backend-->>Store: Complete (ran 1)

A change that depends on a rename not yet answered is not sent until it answers. Were the rename refused, the change would act on whatever else has that name on disk. Store::sync holds back the first folder change that touches an unsettled rename, and every change after it, and holds a file’s save or move into or out of such a folder for the next sync.

While a rescan is in flight, Store::sync sends nothing. The rescan’s listing must describe the files as the commands before it left them, or a file moved after it was sent would read as one deleted and another made.

Writes and crash safety

Every write rests on two operations of Fs. create writes a new file and appears whole or not at all, refusing a name already taken. replace writes over a file, and after it, or after a crash at any point, the path holds the old contents or the new, never part of either.

On the desktop

replace writes the temporary file, syncs it to the disk, renames it over the target, and then syncs the target’s folder, so on macOS and Linux the rename survives a power cut as well as a crash. Windows cannot open a folder to sync it, so there a rename survives a crash but may not survive a power cut. create does the same with a hard link in place of the rename, because a link refuses a name already taken; on a volume without links it falls back to a check and a rename. A rename is refused where another entry is at the target, except a rename that only changes case, which finds the entry itself there. Folder syncs run on Unix only.

The order of a pass

Store::sync brings the files level with the workspace. A Pass::Files writes the files at once, on any frame that changed the list. A Pass::Full also writes the working copies and the index, at most every 2 seconds and on eframe’s 5-second autosave, so a crash loses the edits made since the last one. At exit, Store::close waits for the saves and renames in flight, then runs a Pass::Last, repeating until nothing more is sent, so the last index carries every save’s fingerprint. One pass sends, in order:

  1. the folder changes, except removals;
  2. each asset’s new file, rename, or save;
  3. the deletions of assets gone from the workspace;
  4. the folder removals, once what was in them has moved;
  5. one Commit: the new working copies, then the index, then the deletions of the working copies the index no longer names.

So a working copy exists before an index names it, and is deleted only after the index stops naming it. An unsaved edit’s working copy is dropped only once its save has answered. A save whose working copy holds an older edit carries it (store::Stale) and writes the copy of what it saves over that copy before the file, so a crash after a save lands and before the next index leaves a working copy of what the file holds. The next open sees that and does not count the asset as unsaved. A copy newer than the save is left alone, and after a crash it comes back over the file as a change made outside drawbar would. A save whose working copy holds exactly the bytes it writes (store::Cmd::Save’s promote) moves the copy into place instead of writing the bytes again: on the desktop by a rename, or a hard link where the file is new, and in the browser’s private storage by move(). It needs no room of its own, so a save on a nearly full disk does not fail for want of the room the copy already takes. Where the backend cannot move it, as in a picked folder, across volumes or on one without links, the save writes the bytes as any other does.

A save over a file sends the file’s fingerprint and lands only where the file still holds it: its stat is the one taken, or else its CRC is. A file that moved or changed answers Failure::Moved, the edit stays unsaved, and a rescan follows. A deletion checks the same way, and a file that changed is left. A save of a new file that finds its name taken puts the asset under a free name in the library’s top level at the next sync.

The index is written only once it holds something no file says (tags, a working copy, or a slot an asset came off), or once drawbar has written to the library. An unchanged index is not written again.

Opening, the lock and read-only

Where .drawbar/ exists, opening takes the lock first, then reads the index and the working copies it names, so no other drawbar changes them in between. Nothing else is written before the index is read. A library that opens read-only lets the lock go. One that may be written is swept: everything in tmp/ but the rescues, every working copy the index does not name, and, once the walk has found them, the .drawbar-tmp siblings. A library drawbar has never written holds nothing to sweep, and its lock is taken at the first write.

On the desktop the lock is an exclusive File::try_lock on .drawbar/lock, held for as long as the backend lives. A library opens read-only when its index is newer, holds what this build does not know, or does not read, when a working copy the index names does not read, when the index is missing but working/ is not, or when another drawbar holds the lock. Working copies are found only through the index, so a sweep without it would delete every one, and an index put back finds them only where they were. Once listed, such a library asks (Browser::ask_unindexed): Keep read-only, or Open without them, which, confirmed with the number of edits it loses, sends Cmd::DropUnindexed and opens the library again. The command deletes the copies only while there is still no index. In the browser’s private storage that answer is the only way to them short of clearing the site’s data.

An index that does not parse is damaged. The library opens read-only, and once listed it asks (Browser::ask_damaged): Keep read-only, or Set it aside and open without it, which, confirmed, sends Cmd::SetAside and opens the library again. The command renames the index to .drawbar/library.ron.damaged-<n>, the first free n, unless it reads again by then; nothing deletes it. While a set-aside index is in .drawbar/, the working copies it named are kept: a missing index does not make the library read-only, no open sweeps working/, and the next generation is always above every copy there, so none is written over.

A write can also find the library closed to it later: another drawbar took the lock first, .drawbar/ could not be made, or another drawbar wrote the index. A lock does not reach every writer: a browser’s Web Lock is unseen by another browser and by the desktop app. So before a commit writes anything, it compares the length and CRC-32 of the index on disk with those this backend last read or wrote (Fs::last_index), or checks that there is still none, and the index is put only over the file it looked at. Where they differ, the commit writes nothing, not even its working copies. Then the library turns read-only, every save in flight counts as unsaved again, and edits stay in memory. An open that fails outright keeps nothing.

In the browser

Files in the private file system are written by crates/drawbar/library-writer.js, a dedicated worker, because the sync access handles that write in place exist only in workers. The page drives it with one request at a time, each answered once:

RequestDoes
lockHold .drawbar/lock open for as long as the worker lives. False when another worker holds it.
beginCreate or empty a file and hold it open for writing.
writeWrite a transferred ArrayBuffer at an offset.
endFlush the file and let it go.
abandonLet a file go, if it is held, and delete it.
unlockLet go of the lock and every file held, as the library is let go, so the next library’s lock, which may be this library’s again, never finds them still held.
sequenceDiagram
    participant Page
    participant Worker as library-writer.js
    participant OPFS
    Page->>Worker: begin .drawbar/tmp/3.ne5p
    Worker->>OPFS: createSyncAccessHandle, truncate
    loop each 4 MiB chunk
        Page->>Worker: write (at, data)
        Worker->>OPFS: write at offset
    end
    Page->>Worker: end
    Worker->>OPFS: flush, close
    Page->>OPFS: move(folder, name) over the target
    Note over Page,Worker: on failure, abandon deletes it

Every file is staged under .drawbar/tmp/ with its target’s extension, since Chrome reads a file moved to a new extension whole, for a Safe Browsing check, before the move lands. Then move(folder, name) puts it in place, replacing a file already there. In a picked folder the page writes through createWritable, which applies to the file only as the stream closes, and a Web Lock named for the folder, taken with ifAvailable, keeps a second tab to reading. Reads go through File snapshots in 4 MiB slices.

Brave refuses move() everywhere but the private file system. The first NotSupportedError in a picked folder is remembered for the library, and from then on nothing is moved there (store::dom::Moves). A save writes its target in place through createWritable: Chromium writes the stream to a swap file beside it, <name>.crswap, and moves that over the file as the stream closes, so a file already there still changes all at once, and the staged copy is then removed. A save over a file looks at it again just before the stream closes, and aborts where its stat moved. A new file is made empty first, so a crash before its stream closes leaves it empty; one found already holding bytes is another program’s, refused as taken, and a failed write removes only an empty file it made, untouched since. A rename copies the file to its new name and then removes the old one, so one interrupted between the two leaves the file at both names, none lost. The old file is looked at again before it is removed, and one that changed while it was copied is kept and fails the rename. A folder moves this way file by file, and stops at the first file that changed. A rename that changes only a file’s case goes through <name>.<n>.drawbar-move as well, and the next open puts a file it left there back, as it does a folder.

Two things differ from the desktop. A new file’s write checks the name and then moves, in two steps, so in a picked folder another program can write between them. Chrome cannot move a folder whole, so a folder moves file by file, and an interrupted move leaves its files split between the two names, none lost. A rename that changes only case moves through a free name beside the folder, <name>.<n>.drawbar-move, since on a disk that ignores case the new name reaches the folder itself, and a folder is never removed where it is the one moved into.

A closing tab gets no last pass: the browser runs nothing of the page once it has gone, and waits for none of its asynchronous writes. eframe saves when the page loses focus or is hidden, which sends a pass, but nothing waits for it to land. So while Store::losing says that letting the library go would lose an edit (one no working copy holds yet, an asset never written, a command the backend has not run through, or, in a read-only library, a deletion, save, rename, folder or tag change held in memory), a beforeunload listener cancels the event (closing.rs), and the browser asks whether to leave. The page keeps running while it asks, so staying lets the writes land. A browser asks only after the user has interacted with the page, and may close without asking when it discards a tab or quits. A folder or file such a rename left under that name when the tab closed is put back at the next open, under the spelling the index’s rows use, before the listing looks for them; where something else has the name it stays, and the log says so. Only the top level and the folders that hold a row are looked in. The first write to the private file system also asks the browser to keep it through a shortage of space. A tab opens a new library only once the one before it has run its last command and let go of its lock.

Fingerprints, reconciliation and conflicts

A fingerprint is a file’s length and modification time, nanoseconds on the desktop and milliseconds in the browser, plus a CRC-32 once something has read the whole file (store::Fingerprint). Length and time are trusted: a file whose stat is the one taken holds what it held. Where they moved, the CRC decides, and a fingerprint without one says only that the file is not known to be the same.

The CRC is taken only where the contents decide something:

  • matching a file at a new path to a row whose file went missing;
  • checking that a file still holds what drawbar read before a save or deletion over it;
  • the resting check of a piano or sample instrument, and any whole read;
  • in the background, for tracked rows that lack one.

Every read takes the CRC afresh, so a file rewritten under its old length and time is fingerprinted by what it now holds. The derived cache holds no CRC; a fingerprint’s CRC only ever comes from reading the file.

When drawbar looks again

The store rescans when the window comes back into focus, and before a send to the instrument, which waits for the answer and goes ahead only if nothing in the send queue changed on disk (Store::hold_send). It also rescans after a failure that may mean the disk changed: a save refused because the file changed, a refused rename, a failed command, or a read of a file that is gone. While an open’s listing is in flight, a rescan looks only at the files read so far (Cmd::Check), and the full rescan follows once the listing is complete.

A rescan rereads a file drawbar holds only where its stat moved. An unread file’s new stat replaces its fingerprint, and nothing reads it.

Matching

store::diff::match_files decides which file is which asset:

flowchart LR
    found["a listed file"] --> named{"known path?"}
    named -- yes --> stat{"stat as known?"}
    stat -- yes --> same["unchanged"]
    stat -- no --> crc{"CRCs known<br/>and equal?"}
    crc -- yes --> same
    crc -- no --> edited{"unsaved edit?"}
    edited -- no --> adopt["changed: shown<br/>as it is now"]
    edited -- yes --> conflict["changed: rebased,<br/>and the user is asked"]
    named -- no --> match{"length and CRC<br/>match one missing<br/>row, one to one?"}
    match -- yes --> renamed["renamed outside drawbar:<br/>id, tags and origin follow"]
    match -- no --> arrived["a new asset"]

A row whose file is nowhere, and that no new file matches, has vanished. One with nothing precious leaves the list as deleted outside drawbar. One with tags, an unsaved edit, a slot it came off or a place in the queue stays, marked missing. At open, a vanished row with a working copy comes back from that copy, marked missing, and one without a copy but with something precious is kept in the index as a lost row, shown as missing.

At open, a new file can be a row moved only where its length is that of a row not found whose CRC is known. Such a file is listed as an asset of its own, and once the walk ends, its CRC is taken a few files at a time. It then gives itself up to the row it matches, unless it was edited or queued meanwhile, and its tags move to the row.

Conflicts

A file changed on disk under an unsaved edit is rebased: the file’s contents become the baseline, and the edit stays. The user is then asked:

  • Keep mine leaves it so. The next save writes the edit over the file, whose new fingerprint it now expects.
  • Take theirs reverts to the new baseline.
  • Keep both writes the edit as a new file beside it under a free name, and reverts the original.

Neither revert writes the changed file again.

At open, a file that changed under a working copy raises the same question, unless the working copy holds what the file now holds. An edit’s copy is made again over the file as it is now, and asks only where the file changed and does not already hold the edit. One that no longer applies is kept as it was.

Names

A library can be copied between macOS, Windows and Linux, so every name drawbar creates follows the strictest rule of the three (store/names.rs). A name is refused when it is empty, . or ..; holds / \ : * ? " < > | or a control character; starts or ends with a space; starts with a dot, which hides it; ends with a dot, which Windows drops; is a Windows device name such as CON or LPT1, in any case and with any extension; or is longer than 255 bytes of UTF-8.

On Windows, a file or folder already in the library under a name Windows cannot open (one with a forbidden or control character, a trailing space or dot, or a device name) is refused by name before drawbar opens it (names::windows_refusal).

A name the user typed is refused with its reason. A name the app chose is made to fit instead (names::portable): forbidden characters become -, control characters go, spaces and dots are trimmed from the ends, a device name gains a leading _, and an overlong name is cut, keeping its extension.

Two names in one folder collide when they are equal lowercased by Unicode’s mapping (names::key), since APFS and NTFS compare names without case. No normalization form is applied, so a precomposed é and e with a combining accent are two keys. A free name is numbered before the extension: c3 2.wav, then c3 3.wav (names::free).

The name box in a document’s header edits the name a sample or piano stores inside itself, and leaves the file’s name alone. An instrument from the original Sample Library stores none, so its box renames the file.

Where a name is taken, Folders::clash says by what: an asset, a folder, a lost row, or a file drawbar does not hold. The user chooses Overwrite, offered only where the occupant is an asset with nothing unsaved, which writes the new contents into its file and keeps its tags. The asset renamed or moved onto the name goes once that save lands, as a removal by hand does (Store::take_left), and stays where it does not, or where it was edited meanwhile (Workspace::save_over); Keep both, under the free name; or Cancel. A folder already holding two entries under one key refuses the name outright. Such duplicates, found on a disk that tells case apart, are flagged and logged and never renamed, since either name may be the one other files refer to.

Opening and switching libraries

A window has one library open at a time, and drawbar opens the last one again at start. The recent list always offers the default library. A picked folder is called by its folder’s name where the default library says This computer.

Switching runs the last pass of the library being left, so its unsaved edits stay in its working copies and come back unsaved at its next open. Views stay in the window (see Views and kept assets), and the left library’s assets come off the send queue. A read-only library cannot keep unsaved edits, so leaving one asks before discarding them. A library that cannot be written holds what is opened into it in memory.

In the browser:

  • Firefox and Safari have no showDirectoryPicker, so Open library folder… is absent and the library is always the private file system. Brave ships the API turned off; the item is grayed out until brave://flags/#file-system-access-api is on.
  • A picked folder’s write permission lasts until drawbar.app closes, unless the user allowed the site on every visit. A handle restored from IndexedDB comes back without it, so at start drawbar opens the private library, and File ▸ Reconnect name asks again on a click. If the user refuses, the open library stays open.
  • The page cannot reveal a picked folder in the file manager, and the origin’s storage quota does not apply to one. Help ▸ About drawbar shows how much of the quota the private library uses.
  • The first write to the private file system calls navigator.storage.persist(). Firefox asks the user; other browsers decide for themselves. Where the browser gives the page no storage, as some private windows do, drawbar says so and the library lasts until the tab closes.
  • The Web Lock that keeps a second tab to reading cannot see the desktop app’s file lock on the same folder. Two tabs that pick the same folder at the same moment can also both open it to write. The first open of each picked folder says once not to open it in the desktop app at the same time (libraries::SHARED), and its entry in the recent list keeps warned: true.

What lives where

WhatDesktopBrowser
Library filesThe library folderThe private file system’s root, or the picked folder
Index, working copies, temporary files, lock.drawbar/ in the library.drawbar/ in the library
Theme, panels, Show all fileseframe’s store: app.ron in the app’s data foldereframe’s store, in local storage
MIDI on or off, recent librarieseframe’s store (drawbar.midi, drawbar.libraries)
Recent picked folders, as handlesIndexedDB: database drawbar, store libraries, key recent: {id, handle, warned}
The derived cachelibrary-cache.ron beside app.ron, outside the default libraryIndexedDB: database drawbar, store files

A preference is never kept in a library, and a library is never kept in preferences. A folder handle can be kept only in IndexedDB, and it comes back without its permission, so the browser asks again on a click (libraries/web.rs).

Earlier versions kept the library in eframe’s store under drawbar.this_computer, drawbar.folders and drawbar.tags. Nothing reads those keys now. At start, any that holds something is emptied, without a word to the user (store::leave_behind). eframe’s store has no remove, and on the desktop it writes back whatever it holds in memory, so emptying is the way to clear one.

The derived cache

drawbar remembers what a read of each file found, so a file it does not read this session still draws as it did once read (summary.rs, store/cache.rs). Everything in the cache can be read again from the library, so losing it costs only reads. It is never kept in the library. Where it lives on each system is under On the desktop and In the browser below.

What an entry holds

An entry is a file’s length and modification time when it was read, and a Summary of what the read found. It holds no whole-file CRC, so nothing taken from the cache reaches the index’s fingerprints:

FieldMeaning
kindThe kind the browser shows and filters by.
tagThe format tag, which names the family and model: ne5p.
crc32The slot checksum, as Baseline::crc32.
playsThe piano or sample library a program plays, by class and id, as the Needs column shows it.
verdictHow its check went: ok, checked, differs at an offset, failed, or not applicable.
wavsFor a Sample Editor project, the WAVs it names, with / between folders.

There is no name: a row shows its filename.

At each full pass and at exit, Store::summarize puts an entry for every asset that holds what its file does, read and decoded or checked, once per set of bytes. An asset that is unsaved, saving, missing, or still being read or checked has none taken.

Remembered rows

flowchart TD
    listed["a listed file"] --> fresh{"a cache entry<br/>with its length<br/>and time?"}
    fresh -- yes --> remembered["remembered:<br/>drawn from the summary"]
    fresh -- no --> plain["listed:<br/>kind from the extension"]
    remembered -- "picked, opened<br/>or acted on" --> read["read and decoded"]
    plain -- "in view, picked,<br/>opened or acted on" --> read
    read -- "next full pass" --> entry["its entry, put<br/>in the cache"]

As each part of a listing is folded in, and again once the cache’s entries arrive, every unread asset whose file has the length and time of its entry is given the entry’s summary (Store::recall, Workspace::remember). Its verify state becomes VerifyState::Remembered(verdict), LocalEntity::remembered holds the summary, and saved.crc32 takes the slot checksum. The file’s fingerprint is untouched.

A remembered asset is still unread(): it holds no bytes, and an act reads it first. It is not reading(), so a row in view does not read it, while hurry (a pick, an open tab, an act) does. Kind::of, LocalEntity::tag and the Needs column take what the summary says, and with the slot checksum in the baseline, device::link, library::agrees and Differs work as for a file read. An evicted asset keeps its summary, and with it all of this.

An entry whose length or time no longer match the file is ignored, and the file is read when something needs it, which puts a new entry over it. A rescan that finds an unread file changed takes its summary away (Workspace::stale).

No read is asked, in the foreground or behind, until the cache has answered, so a file it remembers is not read meanwhile. A tracked file it remembers is not read in the background; its CRC, where the index lacks one, is still taken from the file.

Following the library

Entries follow the paths of their files: a rename drawbar makes, of a file or a folder, moves them, and so does an outside rename matched by its CRC, at open or on a rescan. A rename that failed moves them back, and a file deleted, inside or outside drawbar, takes its entry with it. Changes made while the entries are still loading are made again over them as they arrive, and an entry put meanwhile stands over the one kept.

Once per open, after a listing that reached every folder and once the cache has answered, the entries of every file the listing did not find are dropped (Cache::keep_only). A listing with unwalked folders drops nothing.

On the desktop

The cache is library-cache.ron in eframe::storage_dir("drawbar"), beside app.ron: ~/Library/Application Support/drawbar on macOS, ~/.local/share/drawbar on Linux and %APPDATA%\drawbar\data on Windows. On macOS and Linux that is the folder the default library is in. It holds one section per library, under the library folder’s path, each with a counter of when it was written; the 8 libraries written most lately are kept. Each entry is a RON tuple of unnamed fields, since a library has tens of thousands of them. The file is read on a thread when the library opens.

At a full pass where something changed and no write is in flight, the file is written off the frame: read again, this library’s section replaced, the others kept, then written to library-cache.ron.tmp and renamed over. One write runs at a time in the process. Closing a library waits for its last write.

Where that folder lies inside the library, or the system names none, the cache is kept in memory only. A file that does not read, or holds another version, gives an empty cache and one line in the log, and the next write replaces it, other libraries’ sections with it.

In the browser

The cache is the files store of the drawbar IndexedDB database, version 2, beside libraries. Each file is one record, keyed <library id>/<path>, where the browser’s own library is id 0 and a picked folder has its id from the recent list. A record’s value is the RON text of (version, entry). Everything is asynchronous; nothing goes through local storage.

A library’s records are read by a key range over its prefix when it opens. A record that does not read, or holds another version, is deleted. If IndexedDB has not answered within 2 seconds, the cache is empty for this open, with one line in the log, and reads go ahead. A write puts each changed entry and deletes each dropped one in one transaction, one write at a time. The page cannot wait, so a library’s last write lands after it is let go. Whenever the recent list is written, the records of picked folders no longer on it are deleted (store::keep_libraries).

Projects naming a WAV

Workspace::projects_naming(&LibPath) answers with Naming { by, unknown }: the projects whose WAV list, from their decode or their summary, names that file, and the projects with neither, which may. A project names a WAV relative to its own folder, and a path that is absolute or leaves the library names nothing (summary::resolve).

USB protocol

nord-usb speaks the protocol Nord Sound Manager uses, worked out from USB captures. It moves bytes to and from the instrument. nord-format owns the bytes.

What works

Verified on an instrument from macOS: inventory, object info, dependencies, geometry, focus, walking the occupied slots, reading and writing programs and set lists, moving, deleting, renaming, duplicating and selecting slots, reading and writing live slots and settings in place, and reading, deleting and writing sample and piano libraries. From Linux, reads and a multi-chunk sample write are verified, and every recorded request is byte-identical to its macOS counterpart. The WebUSB backend is verified for reads and writes from Chrome. Windows passes the replay tests but has not been run against an instrument.

A bundle is read with these primitives alone (nord_usb::bundle): a set list’s dependency rows name its programs by slot, a program’s name its pianos and samples, and each of those is found by name, since an object’s info does not carry the id programs know it by. A piano’s or sample’s info does carry when it was last changed.

Not implemented: writing a bundle to the instrument, firmware updates, and relink.

The wire

Every message is a length-prefixed frame of big-endian 32-bit words with a CRC-16 trailer:

┌────────┬─────────┬───────────┬─────────┬───────────────┬───────┐
│ length │ service │ subsystem │ command │ args…         │ crc16 │
└────────┴─────────┴───────────┴─────────┴───────────────┴───────┘

The CRC is CRC-16/CCITT-FALSE. A response carries the request’s command plus one, with a status word inserted before the echoed arguments. Two things bite. Request codes are not reliably even, so direction is recorded at decode time rather than inferred. And operations are generic primitives parameterized by object class, so one delete command serves every class. The wire carries a class as a position in the partition table the instrument reports, and each model orders its table its own way: the Electro 5 keeps programs at 4. Classes finds each class by its partition’s name (Piano, Samp Lib, Program, Set List, Live, Settings, and on a Stage 4 Organ Preset, Piano Preset, Synth Preset), and a partition with any other name is an unknown class, which drawbar only lists. The table and every partition’s banks are read before any session: the instrument answers both with no HELLO and no session open. The piano and sample libraries refuse rename and copy, and refuse a write named like an object they already hold, matching case.

The instrument reads a message until a short packet ends it. A frame that is an exact multiple of the packet size needs a zero-length packet after it, or the device never answers.

The closing exchanges of a transaction clear the instrument’s progress display. Abandoning a transaction after a progress label has been sent leaves the device stuck until it is power-cycled, which is why every session closes on the error path.

Writing over a slot

The instrument does not overwrite an occupied program, set list, sample or piano slot in place, so a write there deletes the occupant first. drawbar and nord both read the occupant back before the delete and write it back if the new write fails. Both read it into a file under a .partial name, sync it, check its length and checksum, and only then rename it, all before the delete, so a process that dies with the slot empty leaves the whole occupant on disk, and one that dies during the read leaves only a .partial file and the slot as it was. nord writes its rescue file in the working directory, nord-rescued-<bank>-<slot>.<tag>, and deletes that file once the slot holds what it should; drawbar’s handling is under Reading by range. Live slots and settings are written in place.

Writing settings reloads the selected program, so unsaved panel changes are lost. After a write or rename succeeds, drawbar asks for the focus, and if the panel is on the slot just written, selects it again so the keyboard plays the new sound. Nothing is reloaded after a failed write. The instrument reports no progress percentage; progress shows on its own display.

Layering

ModuleRole
wireFraming and codec. No I/O.
transportThe byte pipe, and the only code that touches a device.
sessionThe transaction every operation runs inside.
opTyped operations.
deviceAn instrument as a value: session brackets and its geometry.

Features

FeatureDefaultGives
nusbyesDesktop backend for macOS, Linux and Windows, in pure Rust.
webBrowser backend over WebUSB. Chrome and Edge only.
replayDrive the protocol from captures, with no hardware.
blockingBlock on the async API from synchronous code.
corpusTests against the private capture corpus. Implies replay.
fault-injectionDeliberate protocol faults for research. A wrong value can stall the instrument’s endpoints.

WebUSB handles are not Send, so neither is the Transport trait, which keeps the crate free of any particular async runtime. Building web needs --cfg=web_sys_unstable_apis, which crates/.cargo/config.toml supplies when Cargo runs from crates/.

A write reads its file through the FileSource trait, one transfer chunk at a time, so memory does not grow with the file. The body is read twice: once to check its checksum before the first frame, and again to send it. If the bytes change in between, the last chunk is held back and the write fails unfinished. envelope::verify runs the first of those checks alone, for a caller that must know the file is whole before it touches the instrument.

A read hands its file to the FileSink trait the same way: op::read_into writes each chunk of the body as it arrives, behind the room its CBIN header takes, and the header last, once the body’s CRC-32 is known. It returns the slot’s object info and that CRC-32. op::read_program is read_into into memory, so the two send the same frames. A sink that keeps nothing, std::io::sink(), takes a slot’s checksum without holding its body.

The device reports a body CRC-32 in object info for programs and set lists, and 0xffffffff for pianos, samples and Settings, so the only way to learn what a piano or sample slot holds is to read it. Its object info does report the body’s length, format tag, version and name.

The API documentation is on docs.rs, and Testing covers the replay suite.

Testing

A failing test should say which behavior or contract broke. The rules are in CONTRIBUTING.md. Two file-driven sweeps carry most of the evidence: a specimen joins the format sweep by being readable, and a capture joins the replay sweep by existing.

nord-format

cargo test -p nord-format runs the unit tests, a dispatch test that synthesizes a file for every registered tag and round-trips it through both container generations, the Stage body tests, and the specimen sweep over tests/fixtures: files this crate’s own writers produced, with sidecars recording what was set. The sweep fails unless that tree holds a file of every type the reader dispatches. Each specimen is parsed, round-tripped byte for byte, checked for unnameable values, and has every registry field set and read back without moving another. A piano library or sample instrument, alone or in a bundle, must also index to the bytes a whole read gives each stroke or zone, reading none of the audio to find them. A sample’s outline must answer as a whole read does, and an edit saved as a patch must write the bytes a whole edit writes. An edited piano library streamed from its file must write the bytes the whole library writes, reading one stroke at a time.

With --features corpus, NORD_CORPUS_ROOT names a corpus: any tree of Nord files. The sweep runs over every file in it the reader recognizes, wherever it sits. Claims about every file of a kind, such as a Stage 4 selector staying in panel range or a piano library rebuilding byte for byte, run once per file of that kind, so a tree without that kind runs none of them. Claims about one file live in the <file>.oracle.json beside it: decoded values, the project a sample was rendered from, the file another was edited from. A tree without sidecars still gets the round trips and the per-kind claims. A bit-ownership test flips every claimed body bit in the fixtures and one file of each container shape, and requires that it change only the field that claims it, or be refused. nix build .#nord.nord-format-corpus runs the suite over the private corpus, whose sidecars say the most.

nord-usb

cargo test -p nord-usb --features replay

The replay tests drive the whole stack from recorded captures and check that the bytes the crate emits are the bytes Nord Sound Manager sent. replay is not a default feature. Without it, cargo test -p nord-usb verifies none of the wire encoding. The Nix build enables it.

tests/replay runs one trial per script under tests/scripts, and under the corpus with --features corpus. Every script is checked for framing, and one that declares an intent is driven through an exact-match transport and judged against what it said to expect. One that declares none must name the tests that drive it, or say why nothing does.

# source: nord
# device: Nord Electro 5, firmware v2.04 build 592
# intent: program info 7:11
O 0000001200000006000000010000000006a1

A read that timed out, a write the device did not accept, and a transport failure are steps of their own, so a read the script does not expect fails the replay instead of passing as silence. nord … --record <path> writes a complete script. The step forms, the header keys, the expect values and the intent table are documented in crates/nord-usb/tests/scripts/README.md.

drawbar on an instrument

cargo test -p drawbar runs headless: the device worker talks to a scripted instrument, and no test needs hardware. A separate suite in crates/drawbar/src/device/hardware.rs drives drawbar’s own send paths against an attached instrument: a library over a temporary folder, the browser’s acts, the queue, and the device worker over USB. It sends piano libraries and sample instruments from the files they rest in, replaces an occupied slot, changes a file on disk while it is being sent, and checks that memory stays bounded by the transfer chunk.

The tests write to the instrument, so they are ignored and do nothing unless DRAWBAR_HARDWARE=1. Close drawbar and Nord Sound Manager first, so the interface is free, and run them one at a time:

DRAWBAR_HARDWARE=1 cargo test -p drawbar --lib device::hardware -- --ignored --test-threads=1 --nocapture

nord-cli reads the source objects, reads back what drawbar wrote, and deletes it again; DRAWBAR_HARDWARE_NORD names its binary when it is not nord on the path. The sources are slots on the instrument: DRAWBAR_HARDWARE_PIANO and DRAWBAR_HARDWARE_OTHER_PIANO, two piano libraries of a few megabytes, and DRAWBAR_HARDWARE_SAMPLE, a sample instrument of more than a megabyte. Each test writes only to the last vacant slots of bank 1 and deletes only names it gave, and the last test checks that every class holds what it held when the run started.

Everything at once

nix flake check and nix build .#nord.all run what CI runs. See Building from source.

nix build .#nord.all-corpus runs every suite against the pinned private corpus, and .#nord.<crate>-corpus runs one crate’s. .#nord.all-corpus-full uses the corpus’s R2 tier and needs a seeded store or R2 credentials. .#nord.corpus and .#nord.corpus-full are the corpus trees themselves.

Contributing

CONTRIBUTING.md is the authority on style, tests, tooling and releases. Issues and pull requests are on GitHub.

Two rules bear repeating. All reverse engineering is black-box work for interoperability, from files and USB traffic produced by instruments the researcher owns, with no decompiling of Clavia’s software or firmware. And nothing proprietary is committed: no factory presets, sound libraries, firmware, or files shipped with Clavia software. Fixtures are made by this project’s own tools.

This guide

The guide is an mdBook under docs/. nix build .#docs renders it, and nix develop -c mdbook serve docs previews it with live reload. Every push to master publishes it at drawbar.app/docs, beside the app’s latest release.

Community

Other projects that work with Nord files and instruments. The first two are where drawbar’s Stage support comes from.

Used by drawbar

Chris55/nord-documentation · Christian Florentz · BSD-3-Clause

Byte maps for the Stage 2 and Stage 3 program files and the Lead A1, built by diffing saved files one control at a time, without decompiling anything. It was the first public decode of the Stage 2 and 3 formats, and drawbar’s Stage 2 and 3 field placements come from it. Read it at chris55.github.io/nord-documentation.

ns4decode · Randy · MIT

A Stage 4 file viewer that publishes its offset tables. It was the first public decode of the Stage 4 program and preset files, and drawbar’s Stage 4 field placements come from it.

Other projects

Chris55/ns3-program-viewer · Christian Florentz · GPL-3.0-or-later

A read-only web viewer for Stage 2, 2EX and 3 programs, built on the byte maps above. Open it at chris55.github.io/ns3-program-viewer.

simonflore/opennord · AGPL-3.0

A browser companion for the Stage 4 and Stage 2 that reads, organizes and transfers programs and samples, with a fully decoded Stage 4 program body and hardware-verified USB transfer in both directions. Its documentation is thorough and marks what has been verified on hardware. Open it at opennord.gigmeister.app.

Adding to this list

If you maintain a project that works with Nord files or instruments, open a pull request against this page.

Privacy

drawbar is made by John Moore. Write to contact@drawbar.app with any question about this page.

drawbar at drawbar.app sends two things: anonymous usage and error counts, which you can turn off, and reports you write and send yourself. The desktop app sends only the reports.

Anonymous usage and error counts

These tell us how many people use drawbar, what breaks, and which instruments and languages to support next. Each is one row of short codes. A row never holds a name, a file, a sound, your address or anything that identifies you, and no row can be tied to another, so we cannot follow one person from a visit to an error.

To turn them off, uncheck Help ▸ Share anonymous usage. drawbar also sends nothing if your browser has Do Not Track or Global Privacy Control switched on.

drawbar keeps one value in your browser for these counts: the date of your last visit, so a second visit on the same day is not counted as a new visitor. It is deleted when you turn sharing off.

Every row carries version, the drawbar version. The rows are:

RowSent whenFields
visitdrawbar opensnavigation (a new page or a reload), first_day and first_month (whether this browser has visited today or this month), webusb (whether the browser can connect an instrument), fits (whether the window is large enough), referrer (the website that linked here, without the page), language (the browser’s language, such as de)
start_faileddrawbar could not loadstep (downloading or starting), error (the kind of error)
panicdrawbar crashedlocation (the line of drawbar’s code that crashed), model and firmware of the connected instrument
errorsomething went wrong outside an instrument operationdomain (such as usb, audio or library), kind (such as lost), model, firmware
opan instrument operation failed, or one you asked for finishedop (such as put), class (programs, samples and so on, by name), outcome (ok or the kind of failure), took (under one second, ten seconds, a minute, or longer), model, firmware

The server adds browser (such as chrome 141) and os (such as macos), which it works out from your browser’s user agent and then discards. To visit rows only, it adds country, from the network, to help choose which languages to translate drawbar into. It does not store your IP address, and it keeps no logs.

Rows are kept for three months. Totals made from them, such as visits per month, may be kept longer; they describe everyone, not anyone.

Reports you send

Help ▸ Report a problem and Help ▸ Send feedback send what you write, and an email address if you give one. You choose what to attach: the recent errors, what build and instrument you are running, and the activity log, which names your files. The form shows exactly what will be sent before you send it. These are sent even when sharing is off, because you asked.

Reports are emailed to drawbar’s developer and are never published. They are deleted after three months. When a report is sent, drawbar shows its number. Write to contact@drawbar.app with that number, or the email address you gave, to see what we hold or to have it deleted sooner.

Who else is involved

Cloudflare receives both and stores the counts. Reports are delivered by email to Fastmail, which holds them. Both act for us under their data processing terms. Nothing is sold or shared with anyone else.

If you are in the European Union or the United Kingdom and are not satisfied with our answer, you may complain to your data protection authority.