Toolbox

Suno

See on GitHub

Suno library by JOYCO Studio.

A typed Web Audio engine for interactive apps. Manages audio loading, playback, effects routing, and volume fading — with first-class React bindings.

Install

pnpm add @joycostudio/suno

Quick start

import { Suno } from '@joycostudio/suno'
 
const suno = new Suno({
  manifest: {
    click: { src: '/audio/click.ogg' },
    ambient: { src: '/audio/ambient.ogg', loop: true, volume: 0.6 },
  },
})
 
// Must be called from a user gesture (browser policy)
await suno.unlock()
await suno.loadAll()
 
suno.get('click').play()
suno.get('ambient').play({ loop: true })

Quick start (React)

import { SunoProvider, useSuno, useUnlock } from '@joycostudio/suno/react'
 
const MANIFEST = {
  click: { src: '/audio/click.ogg' },
  ambient: { src: '/audio/ambient.ogg', loop: true, volume: 0.6 },
} as const
 
function App() {
  return (
    <SunoProvider manifest={MANIFEST}>
      <AudioUI />
    </SunoProvider>
  )
}
 
function AudioUI() {
  const suno = useSuno<typeof MANIFEST>()
  const { unlock, unlocked } = useUnlock()
 
  const start = async () => {
    await unlock()
    await suno.loadAll()
  }
 
  return (
    <div>
      {!unlocked && <button onClick={start}>Start audio</button>}
      <button onClick={() => suno.get('click').play()}>Click</button>
    </div>
  )
}

Manifest

A manifest maps string keys to audio asset definitions:

type AudioAssetDefinition = {
  src: string // URL to the audio file
  loop?: boolean // Default loop setting (default: false)
  volume?: number // Default voice volume (default: 1)
}
 
type AudioManifest = Record<string, AudioAssetDefinition>

Pass it to the constructor for typed access:

const MANIFEST = {
  'hero-ambient': { src: '/audio/hero-ambient.ogg', loop: true, volume: 0.6 },
  alert: { src: '/audio/alert.ogg' },
} as const
 
const suno = new Suno({ manifest: MANIFEST })
await suno.loadAll()
 
// Typed — TS knows valid keys
suno.get('hero-ambient').play()

Core API

Suno

import { Suno } from '@joycostudio/suno'
 
const suno = new Suno(options?: SunoOptions)

Options

type SunoOptions<M extends AudioManifest = AudioManifest> = {
  manifest?: M // Pre-register assets
  player?: WebAudioPlayer // Share a player across instances
}

Registry

MethodReturnsDescription
get(key)AudioSourceLoaded source. Throws if not loaded.
tryGet(key)AudioSource | undefinedSafe access.
has(key)booleanWhether the source is loaded.
keys()string[]All registered keys (loaded or not).
entries()RegisteredSource[]Every loaded source with key + definition.
playing()PlayingVoice[]Snapshot of all currently-playing voices.

Loading

MethodReturnsDescription
load(key, options?)Promise<AudioSource>Load a registered asset. Idempotent.
load(key, definition, options?)Promise<AudioSource>Register + load inline.
loadAll(options?)Promise<AudioSource[]>Load every registered asset in parallel.
unload(key)voidStop, dispose, and remove a source (definition stays).

All load methods accept { signal?: AbortSignal } for cancellation.

Playback control

MethodDescription
stopAll()Stop every playing voice.
unlock()Resume AudioContext (call from user gesture).
pause()Suspend AudioContext.
resume()Resume AudioContext.
setMasterVolume(volume)Set master gain (0–1+).
setPlaybackRate(rate)Global rate multiplier. Tape-style: pitch tracks speed.
getPlaybackRate()Current global rate.

Effects

// Chain effect nodes and connect to master output.
// Returns the head node — pass it as play({ output }).
const fx = suno.effect(filter, reverb)
 
suno.get('click').play({ output: fx })

Build chains once at startup; the returned node is reusable across sources and voices.

Events

type SunoEventMap = {
  load: { key: string; source: AudioSource }
  unload: { key: string }
  voicestart: { key: string; source: AudioSource; voice: Voice }
  voiceend: { key: string; source: AudioSource; voice: Voice }
  voicechange: { key: string; source: AudioSource; voice: Voice; type: keyof VoiceEventMap }
  playingchange: { playing: ReadonlyArray<PlayingVoice> }
}
 
const unsub = suno.on('voicestart', ({ key, voice }) => {
  console.log(`${key} started playing`)
})
 
// Call unsub() to remove the listener

Cleanup

await suno.dispose() // Stops everything, closes AudioContext

AudioSource

Returned by suno.get(key). Each call to .play() spawns an independent Voice.

Playing

const voice = source.play(opts?: PlayOptions)
type PlayOptions = {
  volume?: number // Initial voice volume (default: source default)
  loop?: boolean // Loop this voice (default: source default)
  exclusive?: boolean // Stop existing voices on this source first
  playbackRate?: number // Local rate (default: 1)
  output?: AudioNode // Override output (default: source's outputNode)
}

Properties

PropertyTypeDescription
outputNodeGainNodePer-source gain bus.
durationnumberBuffer duration (seconds).
voicesReadonlyArray<Voice>Currently-active voices.
activeCountnumberCount of active voices.
isPlayingbooleanTrue if any voice is active.
loopbooleanDefault loop setting (get/set).

Methods

MethodDescription
stopAll()Stop all voices on this source.
pauseAll()Pause all voices.
resumeAll()Resume all paused voices.
setVolume(v)Source-bus volume (affects all voices uniformly).
getVolume()Current source-bus volume.
setDefaultVolume(v)Default volume for new voices.
getDefaultVolume()Current default volume.
dispose()Stop all, disconnect, clear listeners.

Events

type AudioSourceEventMap = {
  voicestart: { source: AudioSource; voice: Voice }
  voiceend: { source: AudioSource; voice: Voice }
  voicechange: { source: AudioSource; voice: Voice; type: keyof VoiceEventMap }
  volumechange: { source: AudioSource; volume: number }
}

Voice

A single playback instance. Owns its own AudioBufferSourceNode + GainNode.

State

PropertyTypeDescription
state'idle' | 'playing' | 'paused'Current state.
isPlayingbooleanWhether currently playing.
disposedbooleanTrue once disposed; methods become no-ops.

Position

PropertyTypeDescription
durationnumberBuffer duration (seconds).
currentTimenumberCurrent playhead (seconds). Rate-aware, loop-aware.
progressnumberPlayhead as 0–1 fraction.

Control

MethodDescription
play()Start or resume.
pause()Pause at current position.
stop()Stop and reset to start.
setVolume(v)Set volume. Ramps gain if playing (avoids clicks).
getVolume()Current volume.
setPlaybackRate(rate)Local rate (multiplied with global).
getPlaybackRate()Current local rate.
dispose()Stop, fade, disconnect. Safe to call multiple times.
PropertyTypeDescription
loopbooleanGet/set loop (emits loopchange).
effectivePlaybackRatenumberlocalRate * globalRate.
gainNodeGainNodeThe voice's gain node (for custom routing).

Events

type VoiceEventMap = {
  play: { voice: Voice }
  pause: { voice: Voice; at: number }
  stop: { voice: Voice }
  ended: { voice: Voice } // Natural end (non-looping)
  volumechange: { voice: Voice; volume: number }
  loopchange: { voice: Voice; loop: boolean }
  playbackratechange: { voice: Voice; rate: number }
  statechange: { voice: Voice; state: VoicePlaybackState }
}

Mixer

Optional fade controller that sits between your code and Suno. All play/stop calls go through it so Suno is abstracted from fade logic.

import { Mixer } from '@joycostudio/suno'
 
const mixer = new Mixer({
  suno,
  fadeOutDuration: 0.5, // seconds (default)
  fadeInDuration: 0, // seconds (default: instant)
  autoRaf: true, // run its own RAF loop (default: false)
})

When autoRaf is false, plug it into your own loop:

// In your game/animation loop
function tick(delta: number) {
  mixer.update(delta) // delta in seconds
}

Methods

MethodDescription
play(key, opts?)Play through Suno with optional fade-in. Returns Voice.
stop(voice, opts?)Fade-out a voice, then voice.stop() at 0.
stopByKey(key, opts?)Fade-out all voices for a key.
stopAll(opts?)Fade-out every playing voice.
update(delta)Advance fades. delta in seconds.
isFadingOut(voice)boolean
isFadingIn(voice)boolean
dispose()Cancel RAF, clear state. Does NOT dispose Suno.

play accepts PlayOptions & { fadeIn?: number }. Stop methods accept { fadeOut?: number } to override the default.

Properties

PropertyTypeDescription
sunoSunoThe underlying Suno instance.
fadeOutDurationnumberDefault fade-out (seconds). Mutable at runtime.
fadeInDurationnumberDefault fade-in (seconds). Mutable at runtime.

Events

type MixerEventMap = {
  play: { key: string; voice: Voice }
  stop: { key: string; voice: Voice }
  fadestart: { key: string; voice: Voice; direction: 'in' | 'out' }
  fadeend: { key: string; voice: Voice; direction: 'in' | 'out' }
  update: { delta: number }
}

How fading works

Each frame in update(delta):

  1. Advance elapsed time
  2. Compute t = clamp(elapsed / duration, 0, 1)
  3. Linearly interpolate volume from start to target
  4. Call voice.setVolume(volume) (Voice's internal 10ms gain ramp smooths frame jitter)
  5. When t >= 1: complete the fade and call voice.stop() for fade-outs

Edge cases handled automatically:

  • Stopping during fade-in reverses to fade-out from current volume
  • Stopping an already-fading-out voice is a no-op
  • External voice.stop() during a fade cleans up internal state
  • Large delta spikes (backgrounded tab) complete the fade immediately

WebAudioPlayer

Low-level AudioContext wrapper. Lazily constructed (SSR-safe). You rarely use this directly — Suno creates one internally.

import { WebAudioPlayer } from '@joycostudio/suno'
 
const player = new WebAudioPlayer()
 
player.audioContext // Lazily creates AudioContext
player.masterOutput // Master GainNode
player.state // 'running' | 'suspended' | 'closed'
player.isPlaying // true if 'running'
 
await player.unlock()
player.setMasterVolume(0.8)
player.getMasterVolume()
 
await player.pause()
await player.resume()
await player.dispose()

TypedEmitter

Base class for all event emitters. Thin typed wrapper around tiny-emitter.

// All classes (Suno, AudioSource, Voice, Mixer) extend this.
// Consistent API across all emitters:
 
const unsub = emitter.on('event', (payload) => { ... })  // Returns unsubscribe fn
emitter.once('event', (payload) => { ... })
emitter.off('event', listener?)                          // Remove specific or all

React API

import {
  SunoProvider,
  useSuno,
  useUnlock,
  useSource,
  useVoice,
  usePlaying,
  useSunoState,
  Mixer,
} from '@joycostudio/suno/react'

All hooks use useSyncExternalStore internally for efficient subscription without unnecessary re-renders.

SunoProvider

Two modes:

// Self-managed: provider creates and disposes on unmount
<SunoProvider manifest={MANIFEST}>
  {children}
</SunoProvider>
 
// Controlled: you own the instance
const suno = new Suno({ manifest: MANIFEST })
<SunoProvider value={suno}>
  {children}
</SunoProvider>

useSuno

Returns the Suno instance from context. Throws if no provider is mounted.

const suno = useSuno<typeof MANIFEST>()
// Typed — suno.get('known-key') is type-checked

useUnlock

Stable unlock function + reactive unlocked flag.

const { unlock, unlocked } = useUnlock()
 
// Call from a user gesture handler
<button onClick={unlock}>Start</button>

useSource

Reactive handle to a loaded source. Returns null until loaded.

type SourceState = {
  source: AudioSource
  isPlaying: boolean
  voices: ReadonlyArray<Voice>
  volume: number
  loop: boolean
  duration: number
}
 
const state = useSource('ambient')
// state is null until suno.load('ambient') resolves

useVoice

Reactive snapshot of a single voice.

type VoiceState = {
  state: VoicePlaybackState
  isPlaying: boolean
  currentTime: number
  duration: number
  volume: number
  loop: boolean
  playbackRate: number
  effectivePlaybackRate: number
}
 
const state = useVoice(voice)

currentTime updates on voice events, not every frame. For smooth playhead UI, drive your own requestAnimationFrame while isPlaying is true.

usePlaying

Live snapshot of all currently-playing voices.

const playing = usePlaying()
// ReadonlyArray<PlayingVoice>
// Each entry: { key, definition, source, voice }

useSunoState

Player-level state snapshot.

type SunoState = {
  state: AudioPlayerState // 'running' | 'suspended' | 'closed'
  isPlaying: boolean
  masterVolume: number
  playbackRate: number
  unlocked: boolean
}
 
const state = useSunoState()

useEmitterSnapshot

Generic hook for subscribing to any TypedEmitter-based object. Used internally by all other hooks. Useful for custom integrations.

const snapshot = useEmitterSnapshot(
  source, // emitter instance (or null)
  ['voicestart', 'voiceend'], // events to listen for
  (s) => ({ playing: s.isPlaying }), // derive snapshot
  { playing: false } // fallback when null
)

Recipes

Effect chain

const ctx = suno.player.audioContext
 
const filter = ctx.createBiquadFilter()
filter.type = 'lowpass'
filter.frequency.value = 2000
 
const fx = suno.effect(filter)
 
suno.get('ambient').play({ output: fx })
suno.get('click').play({ output: fx })
 
// Tweak live
filter.frequency.value = 800

Shared bus

const sfxBus = ctx.createGain()
sfxBus.connect(suno.player.masterOutput)
 
suno.get('click').play({ output: sfxBus })
suno.get('alert').play({ output: sfxBus })
 
// Duck all SFX at once
sfxBus.gain.value = 0.3

Mixer with manual update loop

const mixer = new Mixer({ suno, fadeOutDuration: 1 })
 
function gameLoop(timestamp: number) {
  const delta = (timestamp - lastTime) / 1000
  lastTime = timestamp
 
  mixer.update(delta)
  // ... rest of game logic
 
  requestAnimationFrame(gameLoop)
}
requestAnimationFrame(gameLoop)

Mixer in React

function AudioUI() {
  const suno = useSuno()
  const mixerRef = useRef<Mixer | null>(null)
  if (!mixerRef.current) mixerRef.current = new Mixer({ suno })
  const mixer = mixerRef.current
 
  useEffect(() => {
    let rafId: number
    let last = performance.now()
    const tick = (now: number) => {
      mixer.update((now - last) / 1000)
      last = now
      rafId = requestAnimationFrame(tick)
    }
    rafId = requestAnimationFrame(tick)
    return () => {
      cancelAnimationFrame(rafId)
      mixer.stopAll({ fadeOut: 0 })
      mixer.dispose()
    }
  }, [mixer])
 
  return (
    <>
      <button onClick={() => mixer.play('click')}>Play</button>
      <button onClick={() => mixer.stopAll()}>Fade out all</button>
    </>
  )
}

Inline loading

// Register and load in one call (no manifest needed)
await suno.load('boss-music', { src: '/audio/boss.ogg', loop: true, volume: 0.8 })
suno.get('boss-music').play()

Cancellable loading

const controller = new AbortController()
suno.loadAll({ signal: controller.signal })
 
// Cancel if the user navigates away
controller.abort()

Volume hierarchy

Volumes stack multiplicatively through the audio graph:

Voice volume  ×  Source bus volume  ×  Master volume  =  Final output
LevelSet withScope
Voicevoice.setVolume(v)Single playback
Source bussource.setVolume(v)All voices on that source
Mastersuno.setMasterVolume(v)Everything

Playback rate

Rate is tape-style: pitch tracks speed.

effectiveRate = voice.localRate × suno.globalRate
ValueEffect
0.5Half speed, octave down
1Normal
2Double speed, octave up
suno.setPlaybackRate(0.5) // Global slowmo
voice.setPlaybackRate(1.5) // This voice plays at 0.75× effective

Package exports

Import pathContents
@joycostudio/sunoCore: Suno, Mixer, AudioSource, Voice, WebAudioPlayer, TypedEmitter, all types
@joycostudio/suno/reactReact: SunoProvider, hooks, + re-exports of core (including Mixer)

Always import from @joycostudio/suno/react in React apps to avoid type mismatches between the two entry points.

Related Toolboxs