NB: best run on Chrome with headphones connected via 3.5mm. Otherwise, check out the video demo.
The core mechanics of this client-side JavaScript app was built in 24 hours using JavaScript and React. The React component rendering process is tightly controlled and 100% optimized for minimal rerendering and reconciliation.
Marble Machine has been inspired by Wintergatan. As a musician myself (violin, piano, orchestra conducting, guitar, and bass) with prior experience in creating looped electronic/acoustic music, I set out to combine my passion for programming and my love for music in one app.
- Does not work in Firefox due to the way Firefox handles (or rather, is unable to handle) a large number of concurrent audio files
- Does not work with most Bluetooth audio devices for the same reason given above
The Instrument
class has the following methods:
Initialize the instrument by passing in a number of measures. This can be changed later.
Changes the instrument's mm.
Returns the instrument's name.
Returns an array of all of its tones
Returns all of the notes that it will play at a given beat
Returns an array of booleans of length mm representing the presence of the given note. This is for the React line component.
The Instrument will remember the given callback for the specific note. This is to allow components to force a rerender of the component that stores the instrument's notes in its state.
Example:
updateNotes() {
this.setState({
muted: this.props.instrument.isMuted(this.props.note),
line: this.props.instrument.getLine(this.props.note),
});
}
this.props.instrument.setUpdateComponentCallback(this.updateNotes, this.props.note);
The instrument will invoke all of its callbacks. If a note is passed in, the Instrument will invoke all the callbacks for the given note.
Example
handleAddNote() {
this.props.instrument.addNote();
this.props.instrument.updateComponents(this.props.note)
}
Adds the note to the given beat.
Removes the note from the given beat.
Plays the note. This method is only for when the user is clicking on the GUI and expects audio feedback after placing a note.
Plays all the notes at the given beat.
Wipes the board clean.
Returns the timestamp of the most recent change.
Undo the most recent change to the given instrument
Example of history handling:
handleUndo(e) {
// Hash of { timestamps: [inst, inst] }
const instrumentUndos = {};
// I use an array because a simultaneous change (namely, hitting
// that undo button) may result in the same timestamp for multiple instruments
this.state.instruments.forEach((instrument) => {
const mostRecent = instrument.getMostRecentHistory();
if (mostRecent) {
if (instrumentUndos[mostRecent]) {
instrumentUndos[mostRecent].push(instrument);
} else {
instrumentUndos[mostRecent] = [instrument];
}
}
});
// We're going to put in here the most recent timestamp's instruments
// plus we'll check the other timestamps to see if they're within
// about 10 miliseconds
let instrumentsToReverse = [];
// Get the most recent timestamp
const keys = Object.keys(instrumentUndos).map(time => Number(time));
const max = Math.max.apply(null, keys);
// instrumentsToReverse = instrumentsToReverse.concat(instrumentUndos[max]);
// Iterate over all keys, put anything within 10 miliseconds in the arr
keys.forEach(timestamp => {
if (max - timestamp < 10) {
let instruments = instrumentUndos[timestamp];
instrumentsToReverse = instrumentsToReverse.concat(instruments);
}
});
instrumentsToReverse = [...new Set(instrumentsToReverse)];
if (instrumentsToReverse.length > 0) {
instrumentsToReverse.forEach(instrument => {
instrument.historyPop();
instrument.updateComponents();
});
}
}
Mutes all notes. If a note is given, mutes only the given note.
Unmutes all notes. If a note is given, unmutes only the given note.
Returns a boolean - true if all notes are muted and false if there is at least one unmuted note. If a note is given, returns a boolean for whether or not the given note is muted.
Exports its internal states as a stringified JSON.
Takes in either a JSON or a stringified JSON and updates itself to the new data. Will break horribly if an invalid JSON is given.
Here is an example of setting up a subclass. Each instrument must have its own setup method in which it sets its this.notes
array and builds up the this.sounds
hash with audio elements. In the constructor, this.name
must be set. Preloading the audio is optional.
import Instrument from './instrument';
class Vibraphone extends Instrument {
constructor(props) {
super(props);
// this.notes
this.setup();
this.name = "Vibraphone";
this._preloadAudio();
}
setup() {
this.notes = [
"0e", "0fs",
"1g", "1a", "1b", "1c", "1d", "1e", "1fs",
"2g", "2a", "2b", "2c", "2d", "2e", "2fs",
"3g", "3a", "3b", "3c", "3d", "3e", "3fs",
"4g", "4a", "4b", "4c"
].reverse();
this.notes.forEach((note) => {
this.sounds[note] = new Audio(`audio/vib_${note}.wav`);
this.sounds[note].url = `public/audio/vib_${note}.wav`;
this.sounds[note].volume = 0.16;
});
}
}
export default Vibraphone;