mattalcock.com/labs

Labs · Library

Chop

The clock is the track.

Chop listens to a piece of audio and builds a TrackMap: tempo, beat grid, bars, sections, drops, kicks, snares, hats, energy and key. It can then play the track and expose its playhead as a clock that patterns and games follow.

How Chop differs from Strudel's sample tools

Strudel can already cut up a sample. Its tools follow TidalCycles: chop, striate, slice and splice divide a sample into a number of equal parts, and loopAt stretches it to a number of cycles you supply. These tools are powerful, but they work by count and by time. None of them knows where the beats are, so they work best on loops that are already trimmed to the bar.

Chop starts from the other end. It listens first, then cuts on musical boundaries:

Strudel / TidalCycles Chop
How it cuts by count: slice(8, ...) gives eight equal parts by music: beats, bars, phrases and named sections
What it needs to know you trim the loop and supply its length in cycles nothing: it finds the tempo and the first downbeat
Where the grid comes from the sample's length divided by n the beat grid found in the audio, from bar 0's downbeat
What you can ask for slice number 3 "bars 17–24", "the drop", "every kick", "the backbeat", "the key"
Whole tracks awkward: a three-minute track divided into 8 parts doesn't land on anything the point: intros, builds, drops and breakdowns are found and named
As a clock no: samples play inside Strudel's tempo clock yes: the track's playhead is the clock, so other things follow the record
Events none: a sample is audio kicks, snares, hats and drops are lists of times, ready to drive a game or visuals

In short, Strudel cuts a sample up and plays it in time with your code. Chop works out where the music is in a whole track, then lets code follow the record.

Use

import { analyse, decodeTrack, TrackPlayer, describe } from "@chop-jump/chop";

const ctx = new AudioContext();
const { buffer, mono } = await decodeTrack(ctx, file);
const map = await analyse(mono, buffer.sampleRate, {
  onProgress: (p, stage) => console.log(Math.round(p * 100), stage),
});
describe(map); // "126 BPM · A minor · 72 bars · 2 drops"

const player = new TrackPlayer(ctx, buffer, map);
player.start();
player.clock.cycle();          // bars since bar 0's downbeat: the timeline games follow
player.bar;                    // the bar of the track that is sounding, following any loop
player.loop(16, 24);           // loop bars 17–24 (bars count from 0)
player.loop();                 // stop looping and play on from where the loop has got to
player.slice(t, 64, 1);        // play beat 64 (bar 16's downbeat) at time t
player.stutter(t, 64, 6);      // beat-repeat that beat's first sixteenth six times
player.muffle(0.7);            // sweep the filter down; muffle(0) opens it again

In Node or a test, analyseSync(samples, sampleRate) does the same synchronously. readWav and writeWav handle WAV files.

Loops are cut on the grid. If the playhead is already past the loop, playback jumps to the loop's start on the next bar line rather than mid-beat. While a loop plays, clock keeps counting bars forward, because it is the timeline that games and patterns follow; bar and playhead tell you which part of the track is sounding.

More DJ moves, all timed to the clock:

player.jump(timeOfBar(map, 16), player.clock.next("1m"));  // cut to bar 16 on the next bar line
player.jump(start, at, { start, end });                    // ...and loop from there
player.duck(0.2, at);                                      // duck the track (dry slices stay up)
player.backspin(at, player.playheadAt(at), 1, 0.5);        // spin the last second back over half a second
player.tapeStop(at, player.playheadAt(at), 0.9);           // slow to a halt
player.playheadAt(t);                                      // what's sounding at any clock time, past or future

Underneath is a Transport: a pure model of where the playhead is through every loop and jump, with no audio. Games ask it what part of the track will be heard at a given moment, and headless tests use it without a browser.

The TrackMap

Field What it is
bpm, beatsPerBar tempo to a hundredth of a BPM, and four beats to a bar
firstDownbeat seconds to bar 0's downbeat; the clock's origin
beats, bars every beat time, and every bar's downbeat from bar 0
sections { kind, startBar, endBar, start, end, intensity }, with kind one of intro, build, drop, breakdown, groove or outro
drops start time of every drop
kicks, snares, hats hits snapped to the grid: kicks on beats, snares on the backbeat, hats on eighths and sixteenths
onsets raw transients, not snapped
energy, intensity per bar, 0 to 1; intensity mixes loudness, low end, bassline, brightness and busyness
bands per bar levels for low, bass, mid and high
key { tonic, mode, name, confidence }
envelope loudness at ten points a second, for drawing
confidence { tempo, downbeat }, 0 to 1

Questions you can ask a map, with no audio needed: barAt, timeOfBar, timeOfBeat, sectionAt, energyAt, intensityAt, between(times, from, to), nextDrop, barRange, beatRange, fingerprint and trackClock.

fingerprint(map) is built from coarse musical facts (tempo to half a BPM, length to the second, bar count, key, sections and drops), so the same file gives the same fingerprint at 44.1 and 48 kHz.

Chopping with a pattern

player.voice() returns a sound voice for Jump. Events carrying { beat, length } play that slice of the track, so a Jump music layer can chop the record with mini-notation on the record's own grid:

sink.define("chop", player.voice());
g.music({
  chop: {
    pattern: "0 0 [2 3] 4 ~ 6 [6 6] 7",
    voice: "chop",
    // Step values are eighth notes into the current bar.
    map: (v, hap) => ({ beat: Math.floor(hap.whole.begin) * 4 + Number(v) / 2, length: 0.5 }),
  },
});

The Asteroids demo uses stutter when your ship is destroyed: the track muffles, and the sixteenth you died on repeats six times on the grid.

How it works

  1. Spectrum. A short-time FFT at about 22 kHz measures energy and spectral flux (how much each band jumped since the last frame) in four bands.
  2. Tempo. Autocorrelation of the onset curve picks a tempo, weighted towards dance tempos. A comb filter across the whole track then refines it to a hundredth of a BPM and finds the beat phase.
  3. Downbeats. Of the four ways to group beats into bars, Chop picks the one where harmony and texture change on the one and claps land on two and four.
  4. Bars. For each bar it measures loudness, low end, brightness and busyness. It also measures bassline: low end between the beats compared with low end on them, which separates a drop's rolling bass from a build's bare kick.
  5. Sections. Phrase boundaries are found where the texture changes, preferring four-bar lines. Each section is named from its intensity, its bassline and its neighbours: the loudest sections with a full low end are drops, the ones right before a drop are builds, and the quiet ones after a drop are breakdowns.
  6. Hits. Kicks are jumps in low end on the beat. Snares are mid-range hits on the backbeat that stand out from the off-beats. Hats are high-band hits on the sixteenth grid.
  7. Key. Chroma from spectral peaks (with parabolic interpolation, for accurate bass notes) is matched against key profiles. Major and its relative minor share every note, so Chop also checks which tonic chord the music sits on at the top of each phrase.

Accuracy

Tested against procedurally rendered tracks where every answer is known: 118 to 140 BPM, 44.1 and 48 kHz, with and without silence before the first beat, one starting straight on a drop, and a quiet copy with added noise.

Measure Result
Tempo within 0.05 BPM on every track
Beat grid within 15 ms of the true beats (typically 1–3 ms)
First downbeat within 15 ms
Sections every boundary found, every section named correctly
Drops every drop found, within 15 ms
Key correct on all four, including major and minor
Speed about 1.5 seconds for a two-minute track in Node

On a real recording (the drum and bass excerpt from librosa's example set), Chop reads 136 BPM, the same tempo librosa's own beat tracker reports for that clip.

Limits