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
nordcommand. 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
| Files | What drawbar does | Tested on an instrument |
|---|---|---|
| Electro 5 programs, live slots, set lists and settings | View, edit, transfer | Yes |
| Stage 2, 3 and 4 programs and live slots, and Stage 3 and 4 presets | View, edit | No |
Sample instruments (.nsmp, .nsmp3, .nsmp4) | Decode, edit, encode, audition, transfer | Playback 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, transfer | Trimmed, 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 them | Nord 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 files | Recognized and kept byte for byte, without editing | |
| Text files | Edit, keep beside your files | Never 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.
| Part | What it is for |
|---|---|
| Top bar | Open, 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 line | The 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.
Menus
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.ne5pandpad.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
| Command | Works on |
|---|---|
inspect | Files: print what is in them |
verify | Files: check that they re-encode byte for byte |
edit | Files: change fields in any editable file |
bundle | Nord Sound Manager bundles: made from the instrument, packed and unpacked |
device | The instrument: what is attached and what it holds |
program, setlist, live, settings | Programs, set lists, live slots and settings on the instrument |
sample, piano | Sample 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.bashregenerates the license notices drawbar shows for its Rust crates. Re-run it when the registry packages drawbar reaches inCargo.lockchange.scripts/glyphs.pyregeneratesdrawbar-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
.cn3library, and ZIP backup bundles behind thebundlefeature.
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
libraryin the folder eframe keeps the app’s data in (eframe::storage_dir):~/Library/Application Support/drawbaron macOS,$XDG_DATA_HOME/drawbaror~/.local/share/drawbaron 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\libraryinstead, falling back to the roaming folder whenLOCALAPPDATAis 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["<root>/"]
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,
)),
),
},
)
| Field | Meaning |
|---|---|
version | The index version. This build writes 1. |
next_id | The id the next new asset takes. |
next_generation | The generation the next working copy is written under. |
tags | Tag names, by tag id. |
assets | One row per asset that holds something its file cannot say, by asset id. |
path | Where the asset’s file is, /-joined from the root. None for a view of a slot kept only as a working copy. |
name | The asset’s name, written only for a row with no path. |
fingerprint | Length, 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. |
tags | Tag ids. |
origin | Written 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. |
working | The 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 thatkeeps: Bytesholds the asset’s bytes whole. One thatkeeps: Editholds 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 = valuesets 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 intmp/. It also holds a slot’s occupant while a write to the instrument replaces it, asnord-rescued-…, which the open’s sweep leaves and offers once the listing is complete, unless it is still named.partial. -
lockis 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:
| Field | Meaning |
|---|---|
id | Names the asset to the index, the selection and the send queue. See Ids. |
name | Its name. While path is set, it is the path’s last component. |
path | Where its file is. None for a view, and for a kept asset not yet placed. |
origin | Where it came from: a file, a slot, the New menu, or a rescue. |
bytes | Its current bytes, a shared Bytes. Empty while it is unread or rests in its file. |
entity | What the bytes decode to, boxed, or None before the decode or where it failed. |
saved | Its Baseline: what it was last saved as. |
kept | Whether it is on this computer, as opposed to a view of a slot. |
pending | Whether an editor holds an edit not yet applied to bytes. |
stamp | Distinct 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. |
remembered | While 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:
| Field | Meaning |
|---|---|
bytes | The saved bytes, or empty where file or unread holds them. |
crc32 | The 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. |
stamp | The stamp of these bytes. The asset is unsaved when stamp differs from its own, or pending is set. |
file | The file holding these bytes, left on disk and read by range (ondisk::OnDisk). |
unread | The 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
unreadis set, its verify state isReading, 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::Readfor 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 theFilesnapshot 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).
- 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.
- 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::Listedparts of 256, and the app folds in four parts a frame. - Anything under a name that starts with a dot is left out of the listing, and
a dot folder is not entered. The
.drawbar-tmpsiblings of interrupted saves are gathered on the way, to be swept. - 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::readshas 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.
| Command | Answered by |
|---|---|
Open | Opened, then Listed parts, then Complete. Only Opened(Err) if it failed. |
Scan | Scanned: the whole tree again, rereading only held files whose stat moved. |
Check | Checked: the named files again, without listing the tree. |
Walk(dir) | Walked, once dir is listed whole ahead of the rest of an open. |
Read | Read: each file, held whole or resting, or why not. |
Fingerprint | Fingerprinted: the CRCs of files whose stat has not moved. |
Save | Saved: the new fingerprint, or why not. |
Rewrite | Rewritten: a resting file written again with an edit, found as a listing finds it, or why not. |
Import | Imported: a copy of a file from outside or of the library’s own, found as a listing finds it, or why not. |
Move | Moved: whether the rename happened. |
Commit | Committed: 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, SetAside | Only 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)movesdirand 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:
- the folder changes, except removals;
- each asset’s new file, rename, or save;
- the deletions of assets gone from the workspace;
- the folder removals, once what was in them has moved;
- 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:
| Request | Does |
|---|---|
lock | Hold .drawbar/lock open for as long as the worker lives. False when another worker holds it. |
begin | Create or empty a file and hold it open for writing. |
write | Write a transferred ArrayBuffer at an offset. |
end | Flush the file and let it go. |
abandon | Let a file go, if it is held, and delete it. |
unlock | Let 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 untilbrave://flags/#file-system-access-apiis 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 keepswarned: true.
What lives where
| What | Desktop | Browser |
|---|---|---|
| Library files | The library folder | The 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 files | eframe’s store: app.ron in the app’s data folder | eframe’s store, in local storage |
| MIDI on or off, recent libraries | eframe’s store (drawbar.midi, drawbar.libraries) | |
| Recent picked folders, as handles | IndexedDB: database drawbar, store libraries, key recent: {id, handle, warned} | |
| The derived cache | library-cache.ron beside app.ron, outside the default library | IndexedDB: 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:
| Field | Meaning |
|---|---|
kind | The kind the browser shows and filters by. |
tag | The format tag, which names the family and model: ne5p. |
crc32 | The slot checksum, as Baseline::crc32. |
plays | The piano or sample library a program plays, by class and id, as the Needs column shows it. |
verdict | How its check went: ok, checked, differs at an offset, failed, or not applicable. |
wavs | For 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
| Module | Role |
|---|---|
wire | Framing and codec. No I/O. |
transport | The byte pipe, and the only code that touches a device. |
session | The transaction every operation runs inside. |
op | Typed operations. |
device | An instrument as a value: session brackets and its geometry. |
Features
| Feature | Default | Gives |
|---|---|---|
nusb | yes | Desktop backend for macOS, Linux and Windows, in pure Rust. |
web | Browser backend over WebUSB. Chrome and Edge only. | |
replay | Drive the protocol from captures, with no hardware. | |
blocking | Block on the async API from synchronous code. | |
corpus | Tests against the private capture corpus. Implies replay. | |
fault-injection | Deliberate 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:
| Row | Sent when | Fields |
|---|---|---|
visit | drawbar opens | navigation (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_failed | drawbar could not load | step (downloading or starting), error (the kind of error) |
panic | drawbar crashed | location (the line of drawbar’s code that crashed), model and firmware of the connected instrument |
error | something went wrong outside an instrument operation | domain (such as usb, audio or library), kind (such as lost), model, firmware |
op | an instrument operation failed, or one you asked for finished | op (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.