This example displays a custom view written in JavaScript.
In the previous example, we covered chords: sets of 3 or more notes taken from the diatonic scale that form structural harmonies.
In this example we're going to show how to write a custom view in JavaScript that displays a colorful representation of the relationships between the notes in chords:
In the above image, each small circle represents a note.
The lines connect notes that were playing at the same time.
The horizontal (X) axis represents time, with each new note appearing to the right of the previous note.
The vertical (Y) axis represents pitch.
The color represents the chord type.
In this example, we show you what you need to know to create a custom view using JavaScript. As such, it's a bit heavy on the JavaScript programming and a bit light on the music theory. We'll be returning to more music theory in subsequent examples.
Wildwood Soundworks devices provide a custom view that you can write to from a JavaScript program.
The custom view supports text, rectangles, circles and lines. You can combine primitives to build up a full-featured custom view.
Custom views were added as part of the Wildwood Bolt release to provide an alternative to the previous generation of hard-coded views.
Although this chord visualizer example was developed for Bolt, it runs on other devices with the latest firmware update.
You can check your device firmware date when you start it up. It's displayed in YYYY-MM-DD format, where YYYY is the year, MM is the month and DD is the day.
If your device firmware predates 2026-10-03, you can use the Update Firmware feature in the Network menu to update to the latest version.
Updated firmware is available for Wildwood Soundworks devices going back to Express v2, which launched in September, 2023.
As noted above, the custom view is built up from individual drawing primitives. These primitives are passed from your JavaScript program to the device firmware using the "display" event.
Each primitive is represented in JavaScript as a JavaScript object, with properties for things like the type of primitive, it's X and Y location, it's color, and optional things that differ amongst primitives, like text, font, circle radius, etc.
The X and Y coordinates follow the common graphics convention of drawing to Quadrant IV, i.e. the X value increases from left to right and the Y value increases from top to bottom.
In general, you only need to supply the properties that are different from the previous primitive. If a property is not supplied, it will default to the current value, as set by a previous primitive.
To pass the JavaScript primitive to the device firmware, you need to create its character string representation. An easy way to do this is to use the builtin JavaScript method JSON.stringify(primitive).
This converts the JavaScript primitive object to JavaScript Object Notation (JSON). The firmware on the device receives the event and adds the primitive to its list of drawing primitives for the custom view.
When it's time to display the custom view, it renders the list of primitives to the display, using the JSON representation to recreate each primitive object and draw it.
This example uses six drawing primitives:
In the example there are functions to create the JavaScript object for each drawing primitive. You can reuse these functions in your own programs so that you don't have to worry about formatting the object yourself.
In the JavaScript object, the primitive name is abbreviated to two or three
characters. For example, drf is the primitive name for "draw-rectangle-filled".
To minimize the number of events and improve performance, you can pass an entire JavaScript array of primitives to the display event.
The primitives you pass to the display event can include an optional primitive identifier. This is an integer that precedes the JSON string. If the firmware detects this identifier, it replaces any previous primitive in the primitive list that has the same identifier. This provides an efficient way of updating parts of the display without having to regenerate all of it.
Wildwood Soundworks devices use a 16-bit color space, meaning each color is represented by a 16-bit number. It uses the RGB565 format, which means there are 5 bits for red, 6 bits for green and 5 bits for blue.
This differs from the familiar 24-bit color space of the web and desktop world, where each color component (red, green and blue) gets a full 8 bits.
The example defines a function called color that maps the familiar 8 bit RGB values to the RGB565 values.
It uses the color function to define a bunch of colors that you can reuse without worrying about creating your own colors from individual color components or bit values.
These include colors that match the colors on other Wildwood Soundworks views, such as hb (for header background), nc (for name color) and vc (for value color), as well as general colors like brightYellow, vividCyan, hotPink, etc.
While on the subject of color, let's consider another aspect of color for devices that can be confusing. A 16 bit color value is made up of two 8 bit bytes and display controllers do not agree on which byte should come first. In the example, you'll see a global variable called swapRgb565 that takes care of swapping the byte order for those devices that need it.
The size of the display and the number of pixels on the screen varies amongst Wildwood Soundworks devices.
To enable you to write a program that runs on all of the devices, you can get the width and height of the display from system properties.
You can also get the swapRgb565 property mentioned above.
You do this using the ws.getProperty method.
In this example, we create a custom view that has the same basic appearance as the other views for the device.
It has a two-line header at the top of the display, a client area in the middle of the display, and a two-line footer at the bottom of the display.
In keeping with the existing views, we use a dark red background for the header and footer, with yellow field names and cyan field values.
The header displays information on Octave, Channel, Transpose, Gain and Reverb.
The footer displays the Chord Type and the most recently strummed Note.
We selected this layout to match the existing views, but you're free to make your custom view look any way you want.
Wildwood Soundworks devices define a number of chord types with fixed integer values. For example, Major is 1, Minor is 2, Seventh is 3.
The example uses these fixed chord type numbers as an index into an array to define chord type names and colors.
As we saw in previous examples, MIDI note numbers represent the semitones in the chromatic scale, beginning at C, with 12 MIDI note numbers per octave, corresponding to the 7 white keys on the piano and the 5 black keys.
Given a MIDI note number, we can get it's offset into the octave by dividing by 12 and taking the remainder.
This operation is called modulo and the modulo operator in JavaScript
is the percent sign (%). If you see midiNote % 12 in code, it
generally means that it's finding the note in the octave.
If you want to convert a MIDI note into a note name, all you need to do is take the MIDI note modulo 12 and use it as an index into an array of 12 note names (e.g. "C", "C#/Db", etc.).
In this example, we want to display three octaves of notes in the client area, so we take the MIDI note modulo 36 (i.e. 36 is 3 times 12).
In previous examples we used ws.fire to send the
note_on and note_off events.
In this example, we're going to use ws.fire with
the display event to send the display primitives.
We're also going to use ws.hook to hook a number of
events.
Hooking an event means that we provide a function that gets called back by the firmware when the event occurs. The function we provide is also called an event handler.
This lets us monitor the operation of the device. We hook events for a number of common things, including octave and channel changes, and note-on. We use this information to keep the header and footer up to date, as well as drawing the new notes in the client area.
Recall that you can run a JavaScript program using either the Run button in the integrated development environment, or by opening a .js file using the File -> Open File menu item.
When you run a JavaScript program that has event handlers and hooks events, those event handlers remain active even after the program terminates. You can clear them out by running another program.
The example defines a function called processNoteOn
that's called for every note that is strummed.
It calculates how much time has elapsed since the previous note_on event to determine which notes are still ringing.
It then calculates the Y and X locations of the new note, and draws a line between the new note and any other notes that are still ringing.
Let's take a brief tour of the source code for the custom view example.
It begins by initializing a number of variables for use throughout the life of the program. Some of these variables are based on system properties, some are initialized based on system defaults, and some are invariants, like colors, chord type names, note names, etc.
It then defines a number of functions to do things like clear the display or update the display with an array of graphics primitives, including a variation that uses primitive IDs to replace previous primitives.
Next there are a number of functions that format the JavaScript objects for the drawing primitives.
Moving up to higher level functions, we see functions to draw the header and footer. These take advantage of the primitive identifier to change only the header and footer, without having to redraw the entire display.
The header and footer functions use a function called centerText, which divides the header or footer into a number of columns so that it can center the text in each column.
Next up is processNoteOn that adds new notes to the notes on the screen and draws the lines between notes that are still sounding.
Following this are all the event handlers for the events the program hooks. These continue to get called even after the program terminates (up until the next program is run).
Finally, at the end we see the calls to ws.hook
to hook all the events of interest.
Press the Copy button below to copy the example to the
clipboard.
// Example: chord-visualizer.js - Display chord note relationships
//
// Copyright 2026 Wildwood Soundworks, LLC. All Rights Reserved.
//
// This file is free to copy/modify for personal/educational use;
// commercial distribution prohibited.
//
// This code is provided "as-is" without any warranty of any kind.
// Properties of the physical hardware
var canvasWidth = ws.getProperty("displayWidth");
if (!canvasWidth) {
canvasWidth = 320;
}
var canvasHeight = ws.getProperty("displayHeight");
if (!canvasHeight) {
canvasHeight = 240;
}
var swapRgb565 = ws.getProperty("swapRgb565") === "1";
var fontWidth = 6;
// Derived properties
var hh = 30; // header (and footer) height
var clientHeight = canvasHeight - (2 * hh);
var midiNotesInClientHeight = 36; // three octaves
var pixelsPerMidiNote = clientHeight / midiNotesInClientHeight;
// Properties for tracking the time it takes for notes to decay to zero
var previousMillis;
var sustainMillis = 3000;
// Properties for replacing the header and footer in the display list
var headerId = 1;
var footerId = 2;
// An array of chord notes to display
var notes = [];
// Function to convert a standard RGB color to a 16 bit value
function color(r8, g8, b8)
{
var r5 = r8 >> 3;
var g6 = g8 >> 2;
var b5 = b8 >> 3;
var rgb565 = (r5 << 11) | (g6 << 5) | b5;
if (swapRgb565) {
rgb565 = ((rgb565 << 8) & 0xff00) | ((rgb565 >> 8) & 0xff);
}
return rgb565;
}
// Basic colors of the Wildwood Soundworks user interfaces
var hb = color(128, 0, 0); // header background
var nc = color(255, 255, 0); // name color
var vc = color(0, 255, 255); // value color
// Colors for displaying the notes and connecting lines
var pureWhite = color(255, 255, 255);
var brightYellow = color(255, 255, 0);
var vividCyan = color( 0, 255, 255);
var hotPink = color(255, 0, 128);
var neonGreen = color( 0, 255, 0);
var brightOrange = color(255, 128, 0);
var vividRed = color(255, 0, 0);
var electricBlue = color( 0, 128, 255);
var brightMagenta = color(255, 0, 255);
var mintGreen = color(128, 255, 128);
var gold = color(255, 200, 0);
var skyBlue = color( 0, 192, 255);
var brightPurple = color(160, 0, 255);
var peach = color(255, 160, 128);
var lime = color(192, 255, 0);
// The numeric values of the Wildwood Soundworks chord types
var chordType = {
NONE : 0,
MAJOR : 1,
MINOR : 2,
SEVENTH : 3,
MINOR_SEVENTH : 4,
MAJOR_SEVENTH : 5,
SIXTH : 6,
MINOR_SIXTH : 7,
SUSPENDED_SECOND : 8,
SUSPENDED_FOURTH : 9,
AUGMENTED : 10,
DIMINISHED : 11,
BLUES : 12,
SCALE : 13,
POWER : 14,
};
// The names of the Wildwood Soundworks chord types
var chordTypeName = [
"N/A",
"Major",
"Minor",
"Seventh",
"Minor Seventh",
"Major Seventh",
"Sixth",
"Minor Sixth",
"Suspended Second",
"Suspended Fourth",
"Augmented",
"Diminished",
"Blues",
"Scale",
"Power",
];
// The mapping of chord types to colors
var chordTypeColor = [
pureWhite, // none
brightYellow, // Major
vividRed, // Minor
vividCyan, // Seventh
neonGreen, // Minor Seventh
brightOrange, // Major Sevent
hotPink, // Sixth
electricBlue, // Minor Sixth
brightMagenta, // Suspended Second
mintGreen, // Suspended Fourth
gold, // Augmented
brightPurple, // Diminished
skyBlue, // Blues
peach, // Scale
lime, // Power
];
// The note names for standard MIDI notes (modulo 12)
var noteName = [
"C",
"C#/Db",
"D",
"D#/Eb",
"E",
"F",
"F#/Gb",
"G",
"G#/Ab",
"A",
"A#/Bb",
"B",
];
// Properties displayed in the header
var octave = 4;
var channel = 1;
var transpose = 0;
var gain = "0%";
var reverb = 0;
// Properties displayed in the footer
var chordName = "C Major";
var strumNote = 60;
var chordType = chordType.MAJOR;
var chordRoot = 48;
function clearScreen()
{
notes = [];
ws.fire("display", "clear");
redrawHeader();
redrawFooter();
}
function updateScreen(g)
{
ws.fire("display", JSON.stringify(g));
}
function updateScreenWithId(g, id)
{
ws.fire("display", id + " " + JSON.stringify(g));
}
function getLine(x0, y0, x1, y1, c)
{
return { dl : { x0 : x0, y0 : y0, x1 : x1, y1 : y1, c : c, } };
}
function getCircle(x, y, r, c)
{
return { dc : { x : x, y : y, r : r, c : c, } };
}
function getFilledCircle(x, y, r, c)
{
return { dcf : { x : x, y : y, r : r, c : c, } };
}
function getRectangle(x0, y0, x1, y1, c)
{
return { dr : { x0 : x0, y0 : y0, x1 : x1, y1 : y1, c : c, } };
}
function getFilledRectangle(x0, y0, x1, y1, c)
{
return { drf : { x0 : x0, y0 : y0, x1 : x1, y1 : y1, c : c, } };
}
function getText(x, y, text, c, b)
{
return { dt : { x : x, y : y, t : text, c : c, b : b, f : 10, } };
}
function getNoteName(note)
{
return noteName[note % 12];
}
function getChordName(type, root)
{
return getNoteName(root) + " " + chordTypeName[type] + " (" + root + ")";
}
function centerText(column, columnCount, y, text, c, b)
{
text = text + "";
var columnWidth = canvasWidth / columnCount;
var pixelsToCenter = (columnWidth - (text.length * fontWidth)) / 2;
var x = (column * columnWidth) + pixelsToCenter;
return getText(x, y, text, c, b);
}
function redrawHeader()
{
var g = [];
var y = 2;
g.push(getFilledRectangle(0, 0, canvasWidth, hh, hb));
g.push(centerText(0, 5, y, "Octave", nc, hb));
g.push(centerText(1, 5, y, "Channel", nc, hb));
g.push(centerText(2, 5, y, "Transpose", nc, hb));
g.push(centerText(3, 5, y, "Gain", nc, hb));
g.push(centerText(4, 5, y, "Reverb", nc, hb));
y += hh / 2;
g.push(centerText(0, 5, y, octave, vc, hb));
g.push(centerText(1, 5, y, channel, vc, hb));
g.push(centerText(2, 5, y, transpose, vc, hb));
g.push(centerText(3, 5, y, gain, vc, hb));
g.push(centerText(4, 5, y, reverb, vc, hb));
updateScreenWithId(g, headerId);
}
function redrawFooter()
{
var g = [];
var y = canvasHeight - hh;
g.push(getFilledRectangle(0, canvasHeight - hh, canvasWidth, canvasHeight, hb));
g.push(centerText(0, 2, y, "Chord", nc, hb));
g.push(centerText(1, 2, y, "Note", nc, hb));
y += hh / 2;
var chordName = getChordName(chordType, chordRoot);
var strumNoteName = getNoteName(strumNote) + " (" + strumNote + ")";
g.push(centerText(0, 2, y, chordName, vc, hb));
g.push(centerText(1, 2, y, strumNoteName, vc, hb));
updateScreenWithId(g, footerId);
}
function processNoteOn(midiNote)
{
var currentMillis = ws.time();
var elapsedMillis = currentMillis - previousMillis;
previousMillis = currentMillis;
var midiOffset = midiNote % midiNotesInClientHeight;
var xSpacing = pixelsPerMidiNote * 2;
var ySpacing = pixelsPerMidiNote;
var x = notes.length * xSpacing;
var y = hh + midiOffset * ySpacing;
y = canvasHeight - y; // invert, so higher notes are higher on screen
if ((x + xSpacing) > canvasWidth) {
clearScreen();
x = 0;
}
var note = {
midiNote: midiNote,
x: x | 0,
y: y | 0,
c: chordTypeColor[chordType],
time: currentMillis,
};
var g = [];
for (var i = 0; i < notes.length; i++) {
if (notes[i].time + sustainMillis > currentMillis) {
g.push(getLine(
note.x, note.y,
notes[i].x, notes[i].y,
note.c));
}
}
notes.push(note);
g.push(getFilledCircle(note.x, note.y, pixelsPerMidiNote / 2, note.c));
updateScreen(g);
}
function onAnimate(count)
{
}
function onChannel(newChannel)
{
channel = newChannel;
redrawHeader();
}
function onNoteDown(channel, note, velocity)
{
chordRoot = note;
redrawFooter();
}
function onNoteExpired(channel, note)
{
}
function onNoteOn(channel, note, velocity, delay_ms)
{
if (channel == 16) {
strumNote = note;
redrawFooter();
processNoteOn(note);
}
}
function onOctave(newOctave)
{
octave = newOctave;
redrawHeader();
}
function onReverbMorph(newReverb)
{
reverb = newReverb;
redrawHeader();
}
function onSynthGain(type, base_gain, stacked_gain)
{
gain = base_gain + "%";
redrawHeader();
}
function onStrumPress(channel, position, velocity)
{
}
function onStrumType(mask, type, mode)
{
chordType = type;
redrawFooter();
}
function onTranspose(newTranspose)
{
transpose = newTranspose;
redrawHeader();
}
ws.hook("animate", onAnimate);
ws.hook("channel", onChannel);
ws.hook("note_down", onNoteDown);
ws.hook("note_expired", onNoteExpired);
ws.hook("note_on", onNoteOn);
ws.hook("octave", onOctave);
ws.hook("reverb_morph", onReverbMorph);
ws.hook("synth_gain", onSynthGain);
ws.hook("strum_press", onStrumPress);
ws.hook("strum_type", onStrumType);
ws.hook("transpose", onTranspose);
clearScreen();
If you're new to JavaScript, you may some unfamiliar conventions in the example. This section explains some of them.
First up is x: x | 0.
The x: part assigns a value
to a propery called x.
The x | 0 part is an
efficient way to truncate a floating point number like 3.14 to an integer
like 3 using the bitwise OR operator, which converts its operands to
32 bit integers.
The code ws.getProperty("swapRgb565") === "1" may
also seem unfamiliar.
The ws.getProperty function returns character
strings. For the swapRgb565 property, these returned character strings
are "0" meaning false -- no swap required -- and "1" meaning true -- swap required.
However, in JavaScript the character string "0" does not evaluate to false, so instead
we use the === or "triple equals" operator (strict equality
comparison operator).
It returns true if the type of the operands is the same and the values are the same. This ensures that the swapRgb565 value is true if the property is "1" and false otherwise.