Image of How I Built VORTEX

How I Built VORTEX

2026/07/31
ARTICLE

Check out the project live or explore the codebase:

Background

I used to play the piano and drums, but a few years ago I stopped playing them actively, simply because my interested shifted.
And since I moved to Australia as an international student, I rarely had time to practice or the space to keep them.

Yet every now and then, I’d miss playing them.
I didn't need a full physical setup. I just wanted something that can bring back the joy of music again, without needing the space or the money.

That led me to build a digital audio workstation (DAW) called Strudel Music Sequencer, my first DAW before VORTEX.
It was functional and the concept was good too. It wasn’t some generic ToDo list and brought something unique to the table.
But the UI/UX was bad, performance needed optimization, and my engineering skills weren’t where there are right now.

VORTEX was born to upgrade that initial idea into something more visually appealing, easy to use, and light-weight.

What VORTEX Delivers

  • Multi-Track Sequencing: Dedicated tracks for Keyboard, Guitar, Bass, Synth, and Drums with customizable settings.
  • Real-Time Audio Visualizer: HTML5 Canvas visualizer that dynamically reacts to the audio context.
  • State Management: Complex audio and UI states across multiple tracks.
  • File Management: Save and open your sequences as json files directly to/from your local machine.
  • Hardware-Inspired UI: Built for intuitive workflow.

Tech Stack & Architecture

Frontend: React, TypeScript, Vite, Tailwind CSS.

State Management: Zustand — one dedicated store per instrument (useKeyboardStore, useGuitarStore, useBassStore, useSynthStore, useDrumStore), plus useGlobalStore for shared state like BPM and useStrudelStore for audio engine.

Audio Engine: Strudel.js, Web Audio API.

Visualizer: HTML5 Canvas driven by requestAnimationFrame.

Melodic Instruments

Every melodic instrument (keyboard, guitar, bass, synth) has the same data structure.
Sequences of 12 notes (c ~ b), each with its own gain and release, sitting under shared instrument settings like bank and gain.

export interface MelodicNoteData {
    note: string;
    gain: number;
    release: number;
}

export interface MelodicTrackData {
    struct: MelodicNoteData[];
    play: boolean;
    gain: number;
}

export interface MelodicInstrumentData extends Record<MelodicTrackName, MelodicTrackData> {
    settings: MelodicInstrumentSettings;
}

Because this is centralized in stores/types.ts instead of redefined per instrument, a component like MelodicBars or InstrumentSettings doesn't care whether it's rendering Keyboard or Guitar.

This also made save/open easy. The whole sequencer state is just five store slices plus the BPM exported to JSON.

Instrument Presentation

Each instrument has its own container (e.g. bass.tsx) that pulls state and actions from its store and passes them down as props.

export default function Bass(){
    const bass = useBassStore((s)=> s.bass);
    const settings = useBassStore((s)=> s.bass.settings);
    const updateBass = useBassStore((s)=> s.updateBass);
    const updateNote = useBassStore((s)=> s.updateNote);
    return(
        <div className="flex flex-col gap-[0.2rem]">
            <InstrumentSettings banks={BANKS} settings={settings} updateInstrument={updateBass}/>
            <div className="flex gap-[0.2rem]">
                <MelodicTracks instrument={bass} updateInstrument={updateBass}/>
                <MelodicBars instrument={bass} updateInstrument={updateBass} updateNote={updateNote}/>
            </div>
        </div>
    );
}

This lets all instruments share almost all their UI code while still having per-instrument controls (drums does not have pitched notes, so it has their own drum-tracks, drum-bars).

Strudel.js Integration

Strudel.js is a live-coding language. You write a string of pattern syntax, hand it to Strudel, and it interprets that string as music.

Something like:

s("[bd <hh oh>]*2").bank("tr909").dec(.4)

Instead of triggering sounds from React, VORTEX has to generate a valid Strudel string every time the grid changes and re-evaluate it.

Learning Strudel.js

AI tools weren’t helpful here. Strudel was niche enough that most models generated wrong syntax.
So I worked with Strudel REPL to manually test which functions existed, what they accepted, and how they composed.
Then designed my-tunes.ts based on the syntax I confirmed worked.

Syncing With Zustand Stores

Every instrument store has a method that turns its own grid data into a Strudel string. For drums, each track is just a space-separated hit pattern:

getDrumStr: () => {
    const { drum } = get();
    if (!drum.settings.play) return "silence";

    const stack = Object.entries(drum)
        .filter(([name]) => name !== "settings")
        .map(([name, data]) => {
            const { struct, play, gain } = data as DrumTrack;
            if (!play) return `// ${name} muted`;
            return `s("${struct.join(" ")}").postgain(${gain})`;
        })
        .join(",\n    ");

    return `stack(\n    ${stack})`;
},

Melodic instruments (keyboard, guitar, bass, synth) are more complex, since each of the 64 steps carries its own note, gain, and release, wrapped in a makeNote() helper function:

let seq = "seq([" +
    trackData.struct.map((obj) =>
        obj.note === "~"
            ? `"~"`
            : `makeNote("${obj.note}", "${bassBank}", ${obj.gain * trackData.gain}, ${obj.release})`
    ).join(", ") +
    "])";

“~“ is a silence symbol, so an empty step just passes through.

MyTunes() pulls the generated string from all five stores plus the global BPM, and merges them into one template literal:

return `
setcps(${BPM}/60/4)

samples('https://raw.githubusercontent.com/Mittans/tidal-drum-machines/main/machines/tidal-drum-machines.json')

function makeNote(n, s, g, r) {
    return note(n).sound(s).postgain(g).release(r);
}
stack(
    stack(${drumStack}.bank("${drumBank}").slow(${drumSlow}).gain(${drumGain})),
    stack(${keyboardStack}.slow(${keyboardSlow}).gain(${keyboardGain})),
    stack(${guitarStack}.slow(${guitarSlow}).gain(${guitarGain})),
    stack(${bassStack}.slow(${bassSlow}).gain(${bassGain})),
    stack(${synthStack}.slow(${synthSlow}).gain(${synthGain}))
).log()
`;

setcps converts BPM into Strudel's internal cycles-per-second, and samples() pulls in an external drum-machine soundfont pack so the drum bank names (e.g. RolandTR808) resolve to real sounds.

Audio Visualizer

Strudel connects its nodes straight to audioCtx.destination internally, meaning there is no hook or event to retrieve the signal it’s producing.
I wanted a visualizer reacting to the actual sound coming out of the speaker, so I had to intercept the connection at the Web Audio API level.

Hijacking AudioNode.connect

Every Web Audio node eventually calls .connect() to route its signal somewhere, usually straight to audioCtx.destination.
So instead of asking Strudel to give me the signal, I override the native connect method to reroute anything headed for the speakers through an AnalyserNode first.

const originalConnect = AudioNode.prototype.connect;

AudioNode.prototype.connect = function (...args) {
    const destination = args[0];
    if (destination === audioCtx.destination && this !== analyser) {
        return originalConnect.apply(this, [analyser]);
    }
    return originalConnect.apply(this, args);
};

Canvas Visualizer

Manipulating DOM elements based on 60 FPS audio data causes constant layout recalculations, overloading the main thread.
This was probably why my previous Strudel Music Sequencer was choppy.
It used D3.js to bind data to DOM elements every frame, which meant layout recalculation and memory allocation 60 times a second.

I chose HTML5 Canvas instead because:

  • A single render loop with requestAnimationFrame, owns the drawing.
  • Frame updates draw straight onto the canvas. No virtual DOM diff, no component re-render, just direct pixel manipulation.
  • Zero DOM manipulation during playback keeps the animation smooth.

Reflection

The hardest part wasn't writing React. It was figuring out Strudel's string syntax manually, testing function by function in the REPL until my-tunes.ts actually worked.

A close second was realizing Strudel gave no way to access its own audio signal, which meant dropping down to the Web Audio API and monkey-patching AudioNode.prototype.connect to intercept sound before it reached the speakers.

Lessons Learned

  • Build dumb, reusable components. Splitting presentation from logic meant five instruments could share almost all their UI without duplicating a single slider or radio button.
  • Write less code. A single shared MelodicInstrumentData type replaced four identical data models. One clean abstraction instead of four maintenance burdens.
  • Understand what's expensive. V1 was choppy not because of bad code, but because DOM manipulation and allocation on every frame are costly. Fixing that meant understanding why Canvas is faster.