Skip to content

Gain control of the tone.js transport in a modern routable typescript-react environment. πŸš‚πŸšƒ

Notifications You must be signed in to change notification settings

richard-unterberg/statetrain

Repository files navigation

statetrain πŸš‚πŸšƒ

Gain control of Tone.js' transport with a routable React application.

Written in TypeScript, bundled with Vite.

For another project, I needed a routable TypeScript-React application with access to a shared Tone.js context. To test the code, I built a small metronome, which required a communication layer to and from the imperatively designed Tone.js library.

Features of this toolkit

  • Register Tone.js on a user interaction and put it and its transport into a React context.
  • Extensible custom hook to retrieve and control Tone-specific actions.
  • Web audio portal.
  • Schedule a synth metronome on the Tone.js transport and visualize it with a React component.
  • Start/stop the global transport via context access.
  • Set BPM (on the fly).
  • Set time signature (this will stop the transport and recalculate the new tick times).
  • Clean, extensible TypeScript code.
  • Modern linting & pre-commit checks.

Table of Contents

  1. Startup
  2. A few words on the implementation
    1. React
    2. Vike
  3. Tone Portal
  4. The useTone() hook
    1. React context: useInternalToneContext()
    2. Zustand: useInternalTransportStore()
    3. Returned states and events
  5. Routing & Rendering: Vike / React
    1. Base URL
    2. Prerendering mode
    3. Page Context
  6. Styling with Uno/Tailwind and TW Styled Components
    1. Theme Configuration
  7. Icons: lucide-react
  8. Aliases
  9. Troubleshooting
  10. "Roadmap"

Startup Process

  1. Clone this repo.

  2. Install dependencies:

npm install or yarn
  1. Run the development preview:
npm run dev or yarn dev
  1. Run the production preview:
npm run prod or yarn prod
  1. Build the production package:
npm run build or yarn build

TODO: add check/linter scripts

A few words on the implementation

tone.js is a JavaScript library that makes it easier to work with the low-level Web Audio API. It provides its own transport, where you can register and accurately schedule events with musical notations like 1m, 8n, etc. Additionally, tone.js enables complex routing scenarios and audio bus systems to work with various audio sources.

βš›οΈ The spicy part: React

Getting an imperative tool like tone.js to work with a modern declarative UI-library can be tricky, especially when it requires explicit user actions to initialize browser API functions, load libraries, and maintain states. See Tone Portal and useTone() for how this was achieved.

πŸ”₯ The hot part: Vike

I wanted to use page routing to extend the examples. While I could have used simpler libraries like react-router, I decided to go a step further and implement a fully capable SSR/SSG framework. In my opinion, the one and only is Vike ❀️

See more information on the local implementation of Vike here.

The <TonePortal> interceptor portal

It needed some kind of "moderator" for AudioContext, and thus the TonePortal was born. It blocks on first page visit the page output, forcing user to click a button, which is linking to load, init tone and put it into the context.

See also React context: useInternalToneContext()

useTone() - a hook to control tone and global states

The useTone() hook is basically our middleware, covering various scenarios and designed to be extended and modified.

It's a good place to keep your actions separated from the rest of the React code. Simply use the hook to retrieve data and handlers to work with.

const SomeComponent = () => {
  const { isPlaying, handlePlay, handleStop } = useTone()

  return (
    <div>
      <button onClick={() => isPlaying ? handleStop() : handlePlay()} type="button">
        {isPlaying ? 'Stop' : 'Play'}
      </button>
      <p>Something is {isPlaying ? 'playing' : 'stopped'}</p>
    </div>
  )
}

Under the hood it does:

const useTone = () => {
  const { transport, ... } = useInternalToneContext()
  const { setIsPlaying, ... } = useInternalTransportStore()

  const handlePlay = useCallback(() => {
    transport?.start() // transport changed
    setIsPlaying(true) // store changed (mostly UI updates)
  }, [setIsPlaying, transport])

  const handleStop = useCallback(() => {
    transport?.stop() // transport changed
    setIsPlaying(false) // store changed (mostly UI updates)
  }, [setIsPlaying, transport])

  return {
    handlePlay,
    handleStop,
    ...
  }
}

Okay, what is useInternalToneContext() doing?

It sets and retrieves the unserialized tone.js instance from a React context, which spans over the whole application and ensures tone.js is accessible before React components get access to it.

Do not use useInternalToneContext() in your page templates - always use useTone()

Aha, and useInternalTransportStore()?

We use a React context for utilizing one instance of tone.js and accessing its methods. It feels right that we want to maintain global serialized data provided within the context. Under the hood, zustand manages this for us.

Do not use useInternalTransportStore() in your page templates - always use useTone()

useTone() returns the following states and actions

Callbacks

  • handlePlay - starts the transport
  • handleStop - stops the transport and resets progress
  • handleChangeBpm - changes the playback BPM of the transport
  • handleChangeTimeSignature - changes the time signature of the transport (this will stop the transport by design)

States

  • tone - tone instance
  • transport - transport of tone
  • bpm - BPM of the transport
  • timeSignature - time signature of the transport
  • loopLength - count of measures before restart
  • isPlaying - play state of the transport

Setters

  • setTone - set context tone instance (should be only set up once)
  • setTransport - set context transport instance (should be only set up once)

Routing & Rendering: vike / vike-react

With vike, which is built on top of the Vite bundler, we can activate modern JavaScript features like SSG, SPA, SSR, and/or client-side page routing within the same framework. Similar to next.js, but more flexible and not bound to React. Thanks to its design, you can easily adapt it to your workflow.

This application uses the vike-react plugin on top of vike.

Base URL

For absolute references bypassing vike's page routing system, I created the APP_CONFIG.viteUrl to guarantee access to the full URL from the page root and any given base.

In vite.config.js, you can set the base, which appends this to the URL. In our case, it's '/statetrain/'.

If you only need to output the base URL, you can use the Vite environment variable ${import.meta.env.BASE_URL}.

Prerendering mode is active (SSG)

To publish to GitHub Pages, the page is built on the server and delivered prerendered to the client. To change that on your local machine, you can go to vite.config.ts and activate rendering on the server (SSR).

// ./vite.config.ts
plugins: [
    ...SomePlugin,
    react(),
    vike({
      prerender: true, // set to false / remove to enable ssr
    }),
  ],

See the full vite.config.ts here

SSR users: See this implementation guide

Vike Page context - Current route

If you need the currently routed url, you can use the usePageContext provided by vike-react:

const pageContext = usePageContext()
const { urlPathname } = pageContext

Icons: Lucide React

This application uses the lucide-react bundle from the Lucide Icon Pack

See all icons

Styling with Uno/Tailwind and TW Styled Components

This application uses uno.css for more scalability and custom presets. The tailwind-preset is active by default, which means you can rely on the classic Tailwind CSS syntax.

To reduce "className-cluttering" I used tailwind-styled-components.

For example, a simple button:

const SimpleButton = tw.button`
  p-2
  bg-darkLight
  border-darkLightBorder
  border-1
  rounded
`

the properties from the used element are provided. for example getting auto-suggestion and passing tw.button's type attribute

<SimpleButton type="button" onClick={someHandler}>Some Text</SimpleButton>

conditional styles in action.

interface LayoutTwProps {
  $fullWidth?: boolean // use $ to not pass it to the actual DOM element
}

export default tw.div<LayoutTwProps>`
  m-auto
  ${p => (p.$fullWidth ? 'w-full' : 'container max-w-screen-lg')}
  px-4
  px-lg-0
`

Configuring Theme Files like tailwind.config.js

In uno.css similiar to the tailwind.config we have a uno.config, where you can set theme variables in the same manner:

Currently used uno.config.ts

Aliases

You will see this absolute import references all over the place. These should be automatically detected when auto-importing via your IDE:

import Something from '#components/Something'

Revisit & Change Aliases

We must set and keep it in sync for vite (vite.config.ts) and your IDE (tsconfig.json)

vite.config.ts: link

resolve: {
  alias: {
    '#pages': path.resolve(__dirname, './pages/'),
    '#components': path.resolve(__dirname, './components/'),
    '#lib': path.resolve(__dirname, './lib/'),
    ...
  },
},

tsconfig.json: link

"paths": {
  "#pages/*": ["pages/*"],
  "#components/*": ["./components/*"],
  "#lib/*": ["./lib/*"],
}

Troubleshooting

Events must be cleared!

I encountered issues with improperly unregistering events in React. We must clear the event from the transport to avoid overlapping sounds or timing mishaps.

Register a React ref for every event you schedule

As the docs suggest, one basic way to properly access registered events with React is to store them as refs, which essentially "unbinds" them from React state updates.

const tickEventId = useRef<number | undefined>()

All registered tone.js events return a number, which is the ID of the event in the internal transport.

This example clears and sets a state change on every quarter note of the transport

const tickEventId = useRef<number | undefined>()

const registerTickEvent = useCallback(() => {
  // important: do the explicit !== undefined check, because eventID can be 0 ;)
  if (tickEventId.current !== undefined) {
    tone?.getDraw().cancel() // this clears all "draw" events
    transport?.clear(tickEventId.current) // here the actual schedule is cleared
  }

  // see time - this is important to use for precision
  const tick = transport?.scheduleRepeat(time => {
    // getDraw() commonly used to draw state updates synced with nearest animation frame
    tone?.getDraw()?.schedule(() => {
      handleTick()
    }, time)
  }, '4n')
  tickEventId.current = tick // set new returned id
  setCurrentPosition(transportLength) // reset
}, [...deps])

By design, tone.js does not recalculate timings internally if something changes in the transport's time. For example, if you change the time signature (thus altering loop timing), you need to manually apply the transport.loopEnd afterwards, if it differs from tone's defaults, to properly recalculate the tick times.

Additionally, we must clear all events on the transport and re-register them if timing changes. At least for now! I am currently working on an event bus where all events will be automatically re-registered after timing or other changes.

Roadmap / Todos:

  • more documentation (linting, pre-commit, types)
  • Template the existing pages and add more tone.js controls and visuals
  • Create an effect bus and display it on one of these pages
  • Create event bus, where events are automatically re-scheduled after transport time has changed
  • Add UI to clear metronome events
  • explain framwork-meta-header -> HeadDefault.tsx

About

Gain control of the tone.js transport in a modern routable typescript-react environment. πŸš‚πŸšƒ

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages