raster

A grid groovebox.

Try it at raster.osk.workers.dev

In short: raster is a grid groovebox that runs in the browser, inspired by the Yamaha Tenori-on and built with React and Tone.js. It has up to 16 layers, seven synth engines, sample chopping with transient detection, Elektron-style parameter locks, and pages that chain into songs. Note timing runs on the Web Audio clock, separate from React rendering, so a dropped frame never delays a note.

raster playing a four-layer song: transport and project name in the header, layers and FM synth knobs in the sidebar, and the note grid mid-playback.

I’ve been fascinated with signal processing, music production and synthesizers for years. One of early examples of instruments that I loved is Yamaha Tenori-on – a 16×16 grid of lights where the playhead sweeps across and lit cells make sound. Ever since I discovered it I understood that grids are the most intuitive way to represent musical sketches for me. As I dove deeper into the world of grooveboxes, drum machines and samplers, the lingering memory of 16×16 grid prevailed and kept hunting me. I decided to build my own grid sequencer, based on everything I learned about music, instruments and process. It’s called raster: up to 16 layers, seven synth engines, sample chopping with transient detection, Elektron-style parameter locks, per-layer FX and pages that chain into songs.

Features

Everything on one screen.

Scroll to look closer.

01 · Sequencing

  • A 16×16 grid with 16 or 32 steps per page
  • Tempo and master level as numbers you drag or type. Scale (pentatonic, major, minor, chromatic) and root note.
  • Up to 16 layers, each with its own pattern, level and mute. Notes on other layers show as faint dots.
  • Per-note probability: drag a note up or down to set its chance of playing, with a tooltip showing the percentage.
  • Parameter locks: click a step number to select that step, then change any knob for that step only
  • Up to 8 pages that play back to back. A new page starts as a copy of the current one, a single page can loop, and switching pages slides the grid.

02 · Sound sources

  • Seven synth engines: synth, FM, AM, membrane, metal, pluck and noise, with an octave setting per layer.
  • Sample layers that play slices of an audio file.

03 · Sound design

  • A full set of knobs for each engine: oscillator, envelopes, modulation and the engine’s own settings, with a drawing of each envelope and a live oscilloscope.
  • A channel strip on every layer: level, pan, low-pass cutoff and resonance, and fine detune (±100 cents).
  • Per-layer saturator, tempo-synced delay and reverb, each with an on/off switch that doesn’t click.
  • Knobs respond to dragging (Shift for fine steps) and arrow keys. Click a value to type it in, and double-click a knob to reset it.

04 · Songs

  • Projects that save automatically, with open, new, duplicate, rename and delete.
  • Export and import a project as a .raster file with its samples inside.
  • Layer presets that save a layer’s sound and work in every project.
  • Samples are kept between sessions.

05 · Interface

  • A monochrome design in light and dark, set to follow the system or chosen in settings.
  • A waveform of the master output behind the header while playing.
  • A phone layout where the grid keeps its size and the controls sit in tabs below it.
  • Playback never stops for editing. Clearing or removing layers and pages, and resizing the grid, all happen while it plays, and anything destructive asks for a second click instead of opening a dialog.
  • Cells light up and fade like LEDs, layers animate in and out, and all motion turns off for people who prefer reduced motion.

Close up

Details.

Chance per note. Drag a note down and it empties from the top. The tooltip shows the percentage.
Close-up of the grid: filled notes, faint dots from other layers, and notes half-filled by their chance.
Other layers show as faint dots, so the playhead never crosses a column that sounds but looks empty.
Knobs and envelopes. Turning release redraws the envelope above it as you drag.
The layer list: fm, memb, metal and break.wav, each with a mute button.
Layers list their source, with mute on the right. Clear and remove appear on hover.
The header: stop button, tempo 112, level −6 dB, root C, scale pentatonic, 16 steps, and the master waveform behind the project name.
The header holds the song transport. The master output draws behind the title while it plays.

Light, dark, phone

raster in its dark theme.
raster on a phone: the grid on top at a fixed size, the controls in tabs underneath.

The dark theme follows the system unless you pick one in settings. On a phone the grid keeps its size and the controls move into tabs.

Project → layers → pages

A project holds one song.

Layers are instruments. Pages are sections of the song in time. A page has no data of its own. Each layer stores one long row of steps, and page p is steps p × steps to (p + 1) × steps of every layer. Switching page changes all layers at once, the way switching pattern does on a drum machine.

projecta songtemposcale + rootmaster level16 or 32 steps per page1–8 pages
page 1page 2
sound · what a preset savessource kind, knobs per kind, sample + slices
songnotes · per-step locks · per-note chance · level · mute
Switching pages slides the whole grid. Every layer moves with it.

Because pages are ranges of steps, notes, parameter locks, probabilities and per-step voice groups worked on pages without extra code. Adding, copying and removing a page all go through one function that rebuilds each layer’s steps from the old ones. Looping a page narrows the range the engine plays. The engine reads that range on every 16th note, so page changes never stop playback and the playhead keeps its place in the bar.

Each layer has two parts. Its sound is the source kind, the knob settings for that kind, and on sample layers the sample and its slices. Its part in the song is the notes, locks, probabilities, level and mute. A preset saves only the sound, so applying one leaves the pattern alone. Presets are shared between all projects.

Samples are stored once, keyed by the SHA-256 of their bytes, and projects refer to the hash. A break used in five projects is stored once. Cleanup deletes any sample that no project or preset refers to. A .raster export contains the project and the samples it uses, and import checks each sample against its hash.

Signal flow

Each layer runs through a channel strip built from Tone.js nodes, in the same order as a hardware mixer channel.

  1. voicespool
  2. low-passfilter
  3. saturatorparallel
  4. levelfader
  5. pan
  6. layer mute
  7. master
  8. out
pan → delay send → delay bus → layer mute
pan → reverb send → reverb bus → layer mute

Synth layers use a pool of 8 voices in rotation. Each voice has its own gain, so a choke can silence it in 3 ms. Sample layers create a player for each hit, or a grain player when time-stretching.

The filter comes before the saturator, so the drive works on the filtered sound. The saturator runs in parallel with a dry path, and switching it on or off crossfades the two over 30 ms instead of cutting over.

Delay and reverb sends are taken after the level fader, so turning a layer down also turns down its echoes.

Each layer has its own tempo-synced delay and its own reverb. A bus is only created while its switch is on. When it’s off it doesn’t exist, so a layer without FX spends no processing on them.

Mute comes after the buses, so muting a layer also silences its tails and echoes. The oscilloscope on a synth layer reads the signal at that point, which matches what you hear.

This whole strip is a voice group. Each layer has a base voice group for ordinary steps, and each locked step gets its own (see parameter locks below). On every step the engine rolls probability first, then chokes, then triggers, so notes on the same step never cut each other off.

Working around Tone.js

Tone.js does the hard parts of browser audio well: a sample-accurate transport on the Web Audio clock, and synths, effects and players ready to connect. Most of the technical work in raster was in the places where its defaults assume a simpler instrument than a sequencer that cuts, locks and switches sounds while it plays.

PolySynth.releaseAll

Only releases notes that are still held, so it can’t cut a tail that is already releasing. A kick’s 1.4 s tail kept ringing through a choke.

raster drops PolySynth and runs its own pool of 8 voices per layer, each behind a gain that drops to zero in 3 ms.

choked tail −44 dB → −78 dB

PolySynth

Can’t wrap PluckSynth or NoiseSynth, because they aren’t monophonic synths.

The same voice pool plays every engine, so all seven behave the same way.

rampTo

Tone schedules about 0.1 s ahead, and a plain rampTo starts at the end of that window. Effects that were switched off were still sounding when they were removed, and a gain set with rampTo(x, 0) played at full level for that tenth of a second, twice as loud.

FX fades start at Tone.immediate() instead, and the saturator is built at its final gains.

toggle click 0.4 → within 0.014 (steady signal: 0.006)

main-thread scheduler

Anything that blocks the main thread stops the music. A window.confirm dialog froze playback mid-bar.

Destructive actions ask for a second click instead, and the app never opens a browser dialog.

ToneAudioBuffer.reverse

Flips the audio in place, which would reverse the sample for every layer that shares it.

Reverse and ping-pong play from a separate reversed copy, made once and cached.

GrainPlayer

Stops grains without a fade, which clicks.

Each granular hit fades out through its own gain.

Tone.Draw

A callback queued just before the transport stops can still run afterwards and light the playhead again.

Draw callbacks check that the transport is still running.

modulationEnvelope.decay

Tone’s default is 0, which a logarithmic knob can’t reach.

That knob starts at 1 ms.

Keeping React out of the audio path

Audio timing is kept apart from React rendering.

  • Notes live in a mutable ref, not React state. The scheduler reads them when it triggers.
  • Timing runs on the Web Audio clock through Tone.js. The playhead is drawn with requestAnimationFrame, so a dropped frame never delays a note.

On a throttled CPU the UI still crawled, because every step re-rendered the whole app. I moved the playhead position into a small store that four components read, styled the playhead with a data attribute, and memoised the grid rows.

Cells light instantly and fade over 90 ms as the playhead passes.

frame time p50 · 8 layers playing · 6x CPU throttle

149ms

from 149 ms. p95 went from 183 ms to 33 ms, including while dragging an FX send.

Parameter locks

Click step 5’s number and the panel switches to that step. Turning cutoff writes a lock, marked with a dot on the knob and on the step.

Parameter locks come from Elektron machines: any knob can take a different value on a single step. Step 9 might get a darker filter and step 13 a longer release, while every other step uses the layer’s settings.

The simple way to build this is to change the shared synth just before the locked step plays. That breaks tails, because the previous note is still ringing through the same voice and changes with it.

So each locked step gets its own voice group, a copy of the instrument and channel strip that is created the first time the step plays. Playing a note never changes an instrument’s settings, and each tail keeps the settings it started with.

The data stays small. Base values are stored per synth kind, so switching a layer from FM to AM and back restores the FM settings. Locks are stored per step. A value resolves as default, then base, then lock.

Channel knobs while step 9 is selected: cutoff shows its locked value, 900, with a dot.
A locked value is drawn in full ink with a dot. Inherited values stay dim.
Step 9 selected in the grid, its column outlined, with a small lock mark next to the step number.
The selected step is outlined. Steps with locks carry a small mark by their number.

Sample chopping

Loading an audio file into a sample layer changes what the rows mean.

Instead of notes in a scale, each row plays a slice of the file: row 1 plays slice 1, row 2 plays slice 2, up to 16. You program a chopped break by placing notes on the grid.

The grid on a sample layer: rows are labelled S01 to S08 instead of note names.
On a sample layer the rows are slices, S01 to S16.
  1. 01

    Region

    Each sample has a region, the part of the file that gets sliced. It has drag handles on the waveform, and the rest of the file is shaded. Slices only fall inside the region, so two layers can use the same recording and chop different bars of it. Moving an edge re-cuts the region right away, with the same slice count and method.

    The waveform editor: a drum break with 16 numbered slice markers and region handles at both ends.
    Slice markers on top, region handles at the bottom corners.
  2. 02

    Cut

    There are three ways to cut:

    • Equal slices across the region, for loops that already sit on the grid.
    • Transient detection. The region is split into 512-sample frames, and a slice starts where the energy rises sharply. The biggest rises are taken first, at least 50 ms apart, and rises smaller than 2% of the biggest are ignored. Each cut then moves back a few frames to where the rise began, so it lands just before the attack. On a test break all 8 hits were cut between 0 and 11 ms early and none late. If the file has fewer hits than slices, the widest slice is halved until the count is reached.
    • By hand: drag a marker or nudge it with the arrow keys. Clicking between two markers plays that slice.

    The slice count (1–16) is a setting. Changing it re-cuts the region with the last method used: transient cuts run detection again and equal cuts stay equal. Hand-placed markers fall back to equal slices, since there’s no good way to add or remove markers that someone placed by ear. Rows above the count are dimmed and silent, but their notes stay, so raising the count brings them back.

    Equal slices, then transient: the markers jump to the hits. Each slice flashes as it plays.
  3. 03

    Play

    A slice plays once through to its end, whatever the step length, unless something cuts it off. A sample layer’s mute group is on by default, so each new slice cuts off the one still ringing. With it on, the tail of a slice under the next one measured −57 dB instead of −18 dB. With it off, slices overlap.

    Start and length, as percentages of the slice, trim each hit, with 3 ms fades at both ends by default. Slices play forward, reverse or ping-pong. Every one of these settings is an ordinary parameter, so it can be locked per step: one hit reversed, another pitched down an octave. The waveform flashes each slice when it plays, after probability, so you can see which slices fired.

    The sample layer's panel: source, file name and load, slice count, equal and transient buttons, the waveform, channel knobs and the first slice controls.
    File, slice count and cut method above the waveform, slice settings below.
  4. 04

    Stretch

    In repitch mode (the default), tune changes speed and pitch together, like a turntable. In granular mode, tune changes only the pitch, and speed (25–400%) changes only the length, with adjustable grain size and overlap.

    Fit sets the region to last ¼, ½, 1, 2 or 4 bars at the current tempo. It’s recalculated on every hit, so it follows tempo changes and region edits. In repitch mode it replaces tune. In granular mode it replaces speed and leaves tune to set the pitch. New samples load with fit at 1 bar, so a loop plays in time as soon as it’s loaded.

    Detection only measures broadband energy, so it can miss a soft hit under a loud one. Spectral flux would fix that if it becomes a problem. Locking the region or slice count per step, as the Octatrack does, is postponed until it gets its own design.

raster · React, Tone.js, Vite

Leave a Reply

Your email address will not be published. Required fields are marked *