... as I am now developing strudel for the same purpose!
This lib is able to parse and manipulate musical rhythms by using nested arrays and algebraic fractions.
This lib is a work in progress.
Here are some example usages of the lib, to get you hooked.
Lets spell the famous bolero rhythm in nested notation:
const bolero = [
// bar 1
[
[1, [1, 1, 1]], // beat 1
[1, [1, 1, 1]], // beat 2
[1, 1] // beat 3
],
// bar 2
[
[1, [1, 1, 1]], // beat 1
[1, [1, 1, 1]], // beat 2
[[1, 1, 1], [1, 1, 1]] // beat 3
]
];
Compared with the standard rhythm notation:
- it is a direct representation in array format
- you could even see the inner brackets as beams
- no need to specify any durations / time signatures
We can now render it and play it back with Tone.js:
import { Rhythm } from 'rhythmical';
// render events
const events = Rhythm.render(bolero, 3);
// play back with tonal
var synth = new Tone.MembraneSynth().toMaster();
const part = new Tone.Part(
(time, event) => synth.triggerAttackRelease('C2', event.duration, time),
events // <- the events are used here
).start(0);
part.loop = true;
part.loopEnd = 3;
Tone.Transport.start('+1');
Lets take the afrobell in 4/4:
const afrobell4 = [[2, 0, 2], [0, 1, 2], [0, 2, 0], [2, 0, 1]];
We can remove the triplet groupings with ungroup:
const afrobell = Rhythm.ungroup(afrobell4);
...which returns the "raw" afrobell pattern:
[2, 0, 2, 0, 1, 2, 0, 2, 0, 2, 0, 1];
We can now apply another grouping:
const afrobell3 = Rhythm.group(afrobell, 3);
[[2, 0, 2, 0], [1, 2, 0, 2], [0, 2, 0, 1]];
You can insert rhythms into one another. Take the rhythm of a famous rock song riff:
const smoke = [[1, 1, 1.5, [0, 1]], [[0, 1], [0, 1], 2, 0]];
As you may know, the riff of the original is 4 bars long. Let's build the next two bars, reusing the first bar:
const smokeOn = Rhythm.insert([[0, 7], 0, 0, 0], smoke.concat(smoke[0]));
yields:
[
[1, 1, 1.5, [0, 1]], // bar 1
[[0, 1], [0, 1], 2, 0], // bar 2
[1, 1, 1.5, [0, 1]], // bar 3 = copied bar 1
[[0, 7], 0, 0, 0] // inserted bar 4
];
Note: The first argument is expected to be ungrouped, while the second argument is expected to have groups. This allows you to reuse rhythmic blocks independent of time signatures.
The insert method has a third argument, which specifies the beat of insertion:
Rhythm.insert([3, 4], [[1, 2, 0, 0]], 2); // from left
Rhythm.insert([3, 4], [[1, 2, 0, 0]], -2); // from right
both yield
[[1, 2, 3, 4]];
- Keep it functional: inputs and outputs can be transformed using function chaining
- Keep it immutable: arrays/objects never change
- Keep it static: no class instances => outputs are primitives, objects or arrays
- strictly typed: typescript first
- Influenced by Tonal, Tidal Cycles and Impro-Visor
A NestedRhythm is an easy way to notate rhythms:
const fourToTheFlour = ['A', 'C', 'E', 'G'];
const waltz = ['C', ['E', 'E'], ['G', 'G']];
const swingCymbal = [1, [2, 0, 1], 1, [2, 0, 1]];
const swingHihat = [0, 1, 0, 1];
- By using just nested arrays, you can express any musical rhythm
- The notation is very similar to normal musical notation
- Similar concept also used by TidalCycles (or see Tone#Sequence)
- The actual content can be any type
To be able to play a rhythm we need:
- absolute time
- absolute duration
We can use Rhythm.render to calculate that:
render(NestedRhythm, duration)
Turns a NestedRhythm to a flat array of TimedEvent:
Rhythm.render(['C', ['E', 'G'], 'B', 'D'], 4);
yields:
[
{ value: 'C', time: 0, duration: 1, path: [[0, 4]] },
{ value: 'E', time: 1, duration: 0.5, path: [[1, 4], [0, 2]] },
{ value: 'G', time: 1.5, duration: 0.5, path: [[1, 4], [1, 2]] },
{ value: 'B', time: 2, duration: 1, path: [[2, 4]] },
{ value: 'D', time: 3, duration: 1, path: [[3, 4]] }
];
- array is now one dimensional
- time and duration are calculated based on the path fractions
Using numbers, we can adjust the duration:
Rhythm.render([1, [0, 3], 0, [0, 1]], 4);
[
{ value: 1, time: 0, duration: 1, path: [[0, 4]] },
{ value: 2, time: 1, duration: 1, path: [[1, 4], [0, 2]] },
{ value: 2, time: 2, duration: 1, path: [[3, 4]] }
{ value: 1, time: 3.5, duration: 0.5, path: [[3, 4]] }
];
- duration is now multiplied by value
Converts a NestedRhythm to a one dimensional Array of FlatEvent. This is like render but without the absolute calculations:
const swingCymbal = [
1, // one
[2, 0, 1], // two with "swing" off
1, // three
[2, 0, 1] // four with "swing" off
];
Rhythm.flat(swingCymbal);
outputs
[
{ value: 1, path: [[0, 4]] },
{ value: 2, path: [[1, 4], [0, 3]] },
{ value: 0, path: [[1, 4], [1, 3]] },
{ value: 1, path: [[1, 4], [2, 3]] },
{ value: 1, path: [[2, 4]] },
{ value: 2, path: [[3, 4], [0, 3]] },
{ value: 0, path: [[3, 4], [1, 3]] },
{ value: 1, path: [[3, 4], [2, 3]] }
];
A FlatEvent consists of
- path: path of fractions to keep the nesting information
- value: the original value
Calculates time of path fractions:
Rhythm.time([[0, 2], [1, 2]])); // yields 0.25
// = (0 + 1/2) / 2
Rhythm.time([[1, 2], [0, 2]])); // yields 0.5
// = (1 + 0/2) / 2
Rhythm.time([[0, 1], [3, 4]])); // yields 0.75
// = (0 + 3/4) / 1
Rhythm.time([[0, 1], [0, 1]])); // yields 0
// = (0 + 0/1) / 1
expect(Rhythm.time([[1, 4], [1, 4]], 4)).toEqual(1.25);
// = 4 * (1 + 1/4) / 4
- the second argument is the time of the whole sequence. If the unit is seconds, we would have a 4s measure, with 4 beats in the bar => 60bpm.
Calculates the duration of a given path:
Rhythm.duration([[0,4], [0,3]], 4)); // 4* 4/3 = 1/3
Rhythm.duration([[0,4], [0,2]], 4)); // 4* 4/2 = 1/2
- This method can be used to determine the length of a note
- the first one could be the length of a triplet in 60bpm
- the second one could be the length of an eights in 60bpm
Calculates time and duration from a FlatEvent. This method is used by render:
const calculated = Rhythm.flat([1, [0, 3], 0, 1])
.map(Rhythm.calculate(4))
.map(({ time, duration }) => ({ value, time, duration }));
yields
[
{ value: 1, time: 0, duration: 1 },
{ value: 0, time: 1, duration: 0 },
{ value: 3, time: 1.5, duration: 1.5 },
{ value: 0, time: 2, duration: 0 },
{ value: 1, time: 3, duration: 1 }
];
- the time value is now the absolute time inside the defined 4s
- the duration is the length of the note inside the subdivision
This format can be used easily to schedule playback, e.g. using Tone.js (see Example 1 at the top)
The following function could be used to play notes with Tone.js:
function playNotes(notes, cycle, synth) {
const events = Rhythm.render(notes, cycle);
const part = new Tone.Part((time, event) => {
synth.triggerAttackRelease(event.value, event.duration, time);
}, events).start(0);
part.loop = true;
part.loopEnd = cycle;
Tone.Transport.start('+1');
}
var pluck = new Tone.PluckSynth().toMaster();
playNotes(['C3', 'D3', ['E3', 'G3']], 2, pluck);
Polyrhythm = different pulse, same duration
playNotes(['C3', 'E3', 'G3'], 2, synth);
playNotes(['C2', 0, 'G2', 0], 2, synth2);
- The two Rhythms will be played in the same amount of time.
- Having two different pulses (3 and 4), this will create a polyrhythm
Polymeter = same pulse, different length
playNotes(['C3', 'E3', 'G3'], 3, synth);
playNotes(['C2', 0, 'G2', 0], 4, synth2);
- The two Rhythms will be played in the same amount of time.
- Having two different pulses (3 and 4), this will create a polyrhythm
A little helper function to get the seconds per measure:
// polymeter
playNotes(['C3', 'E3', 'G3'], Rhythm.spm(120, 3), synth);
playNotes(['C2', 0, 'G2', 0], Rhythm.spm(120, 4), synth2);
// polyrhythm
playNotes(['C3', 'E3', 'G3'], Rhythm.spm(120, 4), synth);
playNotes(['C2', 0, 'G2', 0], Rhythm.spm(120, 4), synth2);
addGroove(string[]): {[chord: string]: number[]}
addGroove(['C7', 'F7']);
yields
{
"C7": [2, 0],
"F7": [0, 2]
}
where each chord is mapped to its personal "track". Playing all the tracks at the same time should return a a seamless rhythm.