Celtic Handpan
The soothing tones of a Celtic handpan are brought to life in this interactive demo, combining classes, p5.js, and the Quadrille API. Each note is represented as a dynamic cell that responds to (mouse and key) presses with sound and color, creating a rich, sensory experience reminiscent of synesthesia. The tones are sourced from the Celtic Handpan Samples, licensed under WTFPL.
This demo reuses the SoundCell
class hierarchy from the Inheritance chapter. Rather than subclassing Quadrille
, it adds a new play()
method directly to the Quadrille
prototype—showcasing JavaScript’s unique ability to extend classes at runtime. This pattern is particularly useful for experimentation and feature proposals.
Quadrille
prototype is a great way to experiment new functionality before proposing it for inclusion in the library.(press cells or keys 0–9 to play notes)
Code
let handpan;// The handpan quadrille grid instance
// Base class for a generic sound-based visual object
class SoundCell {
constructor(name, sound) {
this.name = name; // Label of the sound
this.sound = sound; // p5.SoundFile instance
}
getName() {
return this.name;
}
play() {
this.sound.play();
}
stop() {
this.sound.stop();
}
toggle() {
this.sound.isPlaying() ? this.sound.stop() : this.sound.play();
}
display(cellLength, outline, outlineWeight) {
push();
noFill();
stroke(outline);
strokeWeight(outlineWeight);
ellipseMode(CORNER);
circle(0, 0, cellLength);
pop();
}
}
// Subclass that adds color and visual feedback
class NoteCell extends SoundCell {
constructor(note, sound, color) {
super(note, sound);
this.color = color;
this.alpha = 0;
}
play() {
super.play();
this.alpha = 255; // Reset alpha on play
}
display(cellLength, outline, outlineWeight) {
push();
this.alpha = max(0, this.alpha - 5); // Fade manually each frame
const hue = color(this.color);
hue.setAlpha(this.alpha);
noStroke();
fill(hue);
ellipseMode(CORNER);
circle(0, 0, cellLength);
super.display(cellLength, outline, outlineWeight);
pop();
}
}
// Extend the Quadrille prototype with a cell play() method.
// Showcases how to add behavior to an existing class in JavaScript
Quadrille.prototype.play = function (index = null) {
let { row, col } = { row: this.mouseRow, col: this.mouseCol };
Quadrille.isNumber(index) && ({ row, col } = this._fromIndex(index));
this.read(row, col)?.play();
}
async function setup() {
// Create a canvas that matches the dimensions of a 4x3 quadrille grid
createCanvas(4 * Quadrille.cellLength, 3 * Quadrille.cellLength);
// Define the notes and corresponding display colors
const notes = ['A3', 'A4', 'B2', 'B3', 'B4',
'CSharp4', 'D4', 'E4', 'FSharp3', 'FSharp4'];
const colors = ['red', 'orange', 'yellow', 'lime', 'green',
'aqua', 'cyan', 'skyblue', 'blue', 'purple'];
// Load the sound files and build an array of NoteCell objects
const sounds = [];
for (let i = 0; i < notes.length; i++) {
// Load the sound file using await and string concatenation
const sound = await loadSound('/sounds/' + notes[i] + '.wav');
// Create and store a new NoteCell with the corresponding color
sounds.push(new NoteCell(notes[i], sound, colors[i]));
}
// Arrange the NoteCell instances into a jagged 2D grid layout
const jaggedArray = [
[sounds[0], sounds[1], sounds[2], sounds[3]], // Top row
[sounds[4], null, null, sounds[5]], // Middle row with gaps
[sounds[6], sounds[7], sounds[8], sounds[9]] // Bottom row
];
// Initialize the quadrille handpan with the jagged layout
handpan = createQuadrille(jaggedArray);
}
function draw() {
background(0);
drawQuadrille(handpan, {
tileDisplay: 0,
objectDisplay: ({ value, cellLength, outline, outlineWeight }) =>
value?.display(cellLength, outline, outlineWeight)
});
}
function mousePressed() {
handpan.play();
}
function keyPressed() {
handpan.play(parseInt(key));
}
SoundCell and NoteCell Classes
The SoundCell
and NoteCell
classes are directly reused from the inheritance chapter without modification.
Extending Quadrille
with play()
Instead of creating a new subclass like Handpan
, this demo extends the existing Quadrille
class by attaching a play()
method directly to its prototype. This design keeps the code concise and demonstrates a powerful JavaScript pattern—adding functionality to a class even after it’s been defined.
Quadrille.prototype.play = function (index = null) {
let { row, col } = { row: this.mouseRow, col: this.mouseCol };
// If an index (0–9) is provided, convert it to { row, col }
// using Quadrille’s "protected" _fromIndex() method
Quadrille.isNumber(index) && ({ row, col } = this._fromIndex(index));
this.read(row, col)?.play();
}
The new Quadrille.prototype.play()
method plays the cell at the given index, or—if no index is provided—defaults to the cell under the mouse. It uses read()
to retrieve the cell, and the optional chaining operator (?.
) ensures that play()
is only called when the cell exists.
play()
directly to the Quadrille
prototype, even without altering the original source code.Protected method _fromIndex()
: In many programming languages, methods like this would be marked as protected
, meaning they are intended for internal use but still accessible to subclasses. JavaScript lacks true access modifiers, so the underscore prefix is a naming convention that signals internal or “protected” status.
In the Quadrille
class, several methods follow this convention—such as _fromIndex
, _toIndex
, and _flood
. These are not part of the documented API but remain available for advanced use and library extensions.
Setup & Initialization
The async setup()
function loads all handpan notes as sound files, wraps them in NoteCell
objects with distinct colors, and arranges them in a jagged grid that defines the layout of the instrument.
async function setup() {
// Create a canvas that matches the dimensions of a 4x3 quadrille grid
createCanvas(4 * Quadrille.cellLength, 3 * Quadrille.cellLength);
// Define the notes and corresponding display colors
const notes = ['A3', 'A4', 'B2', 'B3', 'B4',
'CSharp4', 'D4', 'E4', 'FSharp3', 'FSharp4'];
const colors = ['red', 'orange', 'yellow', 'lime', 'green',
'aqua', 'cyan', 'skyblue', 'blue', 'purple'];
// Load the sound files and build an array of NoteCell objects
const sounds = [];
for (let i = 0; i < notes.length; i++) {
// Load the sound file using await and string concatenation
const sound = await loadSound('/sounds/' + notes[i] + '.wav');
// Create and store a new NoteCell with the corresponding color
sounds.push(new NoteCell(notes[i], sound, colors[i]));
}
// Arrange the NoteCell instances into a jagged 2D grid layout
const jaggedArray = [
[sounds[0], sounds[1], sounds[2], sounds[3]], // Top row
[sounds[4], null, null, sounds[5]], // Middle row with gaps
[sounds[6], sounds[7], sounds[8], sounds[9]] // Bottom row
];
// Initialize the quadrille handpan with the jagged layout
handpan = createQuadrille(jaggedArray);
}
Draw
The draw()
function renders the handpan on the canvas by defining an objectDisplay function. This function leverages the display
method of the NoteCell
class to visually represent each cell. Additionally, the tileDisplay
is disabled by setting it to 0
since it’s not needed for this demo.
function draw() {
background(0); // Clear the canvas with a black background
// Define objectDisplay for visualizing NoteCell objects
const objectDisplay = ({ value, cellLength, outline, outlineWeight }) => {
// Call NoteCell's display method
value.display(cellLength, outline, outlineWeight);
};
// Draw the handpan grid using Quadrille's drawQuadrille function
drawQuadrille(handpan, { tileDisplay: 0, objectDisplay }); // Disable tileDisplay
}
Interaction
The handpan.play()
method handles interaction by playing the NoteCell
at a specific position in the grid. If no argument is passed, it plays the cell under the mouse. If an index (from 0
to 9
) is passed, it plays the corresponding note.
Mouse Interaction
When the mouse is pressed, the cell under the pointer is read usingmouseRow
andmouseCol
, and itsplay()
method is invoked:function mousePressed() { handpan.play(); // Plays the cell under the mouse }
Keyboard Interaction
Pressing any key from0
to9
triggers the cell at the corresponding index:function keyPressed() { handpan.play(parseInt(key)); // Maps key to note index }
This design keeps the interaction logic concise while showcasing how play()
adapts to different inputs. It also demonstrates how extending the Quadrille
prototype enables elegant input-driven behaviors.
Further Exploration
Here are some ideas to expand the project and take it in new directions:
- Experiment with other layouts and cell displays: Try arranging the cells in different patterns, such as circular, hexagonal, or diagonal layouts, and explore how this affects the musical experience.
- Enhance cell visuals: Add new visual effects to the cells, such as animations, gradients, or particle effects, to make the interaction more dynamic and visually appealing.
- Implement touch support: Adapt the demo for touch devices like tablets or phones. Consider integrating touch events (
touchStarted
ortouchEnded
) and optimizing for touch delay times to ensure responsiveness. - Add new instruments: Extend the demo by implementing other grid-based instruments. For example:
- Xylophone: Each cell could represent a bar on the xylophone, with different pitches.
- Drum Pad: Create a percussion-focused grid where each cell triggers a unique drum sound.
- Kalimba: Simulate a thumb piano by arranging the cells as “keys” with varying pitches.
- MIDI Controller: Combine sound loops or effects for live music creation using a grid-based device.
- Create a learning app: Build an educational tool to teach users about scales, chords, or rhythm patterns by highlighting cells and providing interactive feedback.
These exercises provide opportunities to deepen your understanding of programming audio/visual interaction design.
References
Quadrille API
- createQuadrille(jaggedArray)
- read(row, col)
- mouseRow
- mouseCol
- drawQuadrille(quadrille, options)
- Display Functions
p5 API
- createCanvas — Creates a drawing canvas on which all the 2D and 3D visuals are rendered in p5.js.
- push — Saves the current drawing state (e.g., styles, transformations) to the stack.
- pop — Restores the most recently saved drawing state from the stack.
- map — Re-maps a number from one range to another, often used to scale values for visual or interactive elements.
- noFill — Disables filling geometry shapes with color.
- fill — Sets the fill color for shapes drawn after this function is called.
- stroke — Sets the outline (stroke) color for shapes drawn after this function is called.
- strokeWeight — Sets the width of the stroke used for lines and shape outlines.
- ellipseMode — Changes the way the parameters of ellipse shapes are interpreted (e.g., center, corner).
- circle — Draws a circle at a specified location and diameter.
- loadSound — Loads an external sound file for playback in the sketch.
- mousePressed
Further Reading
- Handpan — Learn more about the instrument.
- Celtic Handpan Samples — The source of the tones used in this demo.
- JavaScript Template Literals —
- p5.js Sound Library — Official documentation for the sound library.