Suno
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/sunoQuick 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
| Method | Returns | Description |
|---|---|---|
get(key) | AudioSource | Loaded source. Throws if not loaded. |
tryGet(key) | AudioSource | undefined | Safe access. |
has(key) | boolean | Whether 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
| Method | Returns | Description |
|---|---|---|
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) | void | Stop, dispose, and remove a source (definition stays). |
All load methods accept { signal?: AbortSignal } for cancellation.
Playback control
| Method | Description |
|---|---|
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 listenerCleanup
await suno.dispose() // Stops everything, closes AudioContextAudioSource
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
| Property | Type | Description |
|---|---|---|
outputNode | GainNode | Per-source gain bus. |
duration | number | Buffer duration (seconds). |
voices | ReadonlyArray<Voice> | Currently-active voices. |
activeCount | number | Count of active voices. |
isPlaying | boolean | True if any voice is active. |
loop | boolean | Default loop setting (get/set). |
Methods
| Method | Description |
|---|---|
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
| Property | Type | Description |
|---|---|---|
state | 'idle' | 'playing' | 'paused' | Current state. |
isPlaying | boolean | Whether currently playing. |
disposed | boolean | True once disposed; methods become no-ops. |
Position
| Property | Type | Description |
|---|---|---|
duration | number | Buffer duration (seconds). |
currentTime | number | Current playhead (seconds). Rate-aware, loop-aware. |
progress | number | Playhead as 0–1 fraction. |
Control
| Method | Description |
|---|---|
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. |
| Property | Type | Description |
|---|---|---|
loop | boolean | Get/set loop (emits loopchange). |
effectivePlaybackRate | number | localRate * globalRate. |
gainNode | GainNode | The 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
| Method | Description |
|---|---|
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
| Property | Type | Description |
|---|---|---|
suno | Suno | The underlying Suno instance. |
fadeOutDuration | number | Default fade-out (seconds). Mutable at runtime. |
fadeInDuration | number | Default 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):
- Advance elapsed time
- Compute
t = clamp(elapsed / duration, 0, 1) - Linearly interpolate volume from start to target
- Call
voice.setVolume(volume)(Voice's internal 10ms gain ramp smooths frame jitter) - When
t >= 1: complete the fade and callvoice.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 allReact 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-checkeduseUnlock
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') resolvesuseVoice
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 = 800Shared 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.3Mixer 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
| Level | Set with | Scope |
|---|---|---|
| Voice | voice.setVolume(v) | Single playback |
| Source bus | source.setVolume(v) | All voices on that source |
| Master | suno.setMasterVolume(v) | Everything |
Playback rate
Rate is tape-style: pitch tracks speed.
effectiveRate = voice.localRate × suno.globalRate
| Value | Effect |
|---|---|
0.5 | Half speed, octave down |
1 | Normal |
2 | Double speed, octave up |
suno.setPlaybackRate(0.5) // Global slowmo
voice.setPlaybackRate(1.5) // This voice plays at 0.75× effectivePackage exports
| Import path | Contents |
|---|---|
@joycostudio/suno | Core: Suno, Mixer, AudioSource, Voice, WebAudioPlayer, TypedEmitter, all types |
@joycostudio/suno/react | React: 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.