skills/webaudio/SKILL.md
Add sound effects, UI audio, and ambient sound to a web app using the @joycostudio/suno library. Use when the user wants to play audio on button clicks, hover states, game events, or ambient loops, and when they mention @joycostudio/suno, Suno, AudioSource, Voice, or Mixer.
npx skillsauth add joyco-studio/skills webaudioInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
This skill installs and scaffolds sound-effect playback in a web app using @joycostudio/suno. It picks the right entry point (vanilla vs React, with or without a Mixer), writes a typed manifest, and wires an unlock gesture so audio actually plays.
Use this skill when the user wants to:
Do not use this skill for:
The library has four layers. Keep this picture in mind when scaffolding:
Voice → AudioSource bus → [optional effect chain] → masterOutput → speakers
Suno — one per app. Holds a registry of named assets (the manifest) and the underlying WebAudioPlayer.AudioSource — one per loaded asset. Owns the decoded AudioBuffer and a per-source GainNode bus.Voice — one per play() call. Owns its own buffer source + gain node, so multiple voices of the same asset play independently and can be controlled in isolation.Mixer (optional) — sits on top of Suno and adds fade-in / fade-out on a requestAnimationFrame loop.Every source.play() spawns a new Voice — overlapping playback is free. Voices auto-dispose on ended or stop.
pnpm add @joycostudio/suno
# or: npm i @joycostudio/suno / yarn add @joycostudio/suno / bun add @joycostudio/suno
React users import from the /react subpath — same package, no separate install:
import { SunoProvider, useSuno, useUnlock } from '@joycostudio/suno/react'
Ask these questions in order:
SunoProvider + hooks. Otherwise → new Suno({ manifest }) directly.Mixer. Otherwise → Suno alone is enough.as const and pass typeof MANIFEST to Suno<M> / useSuno<M>().Define the manifest once, mount the provider at the root of the audio-using subtree, gate audio behind an unlock gesture.
// lib/audio/manifest.ts
export const AUDIO_MANIFEST = {
click: { src: '/audio/click.ogg' },
hover: { src: '/audio/hover.ogg', volume: 0.4 },
ambient: { src: '/audio/ambient.ogg', loop: true, volume: 0.6 },
} as const
export type AudioManifest = typeof AUDIO_MANIFEST
// app/providers.tsx (or wherever you mount providers)
'use client'
import { SunoProvider } from '@joycostudio/suno/react'
import { AUDIO_MANIFEST } from '@/lib/audio/manifest'
export function AudioProvider({ children }: { children: React.ReactNode }) {
return <SunoProvider manifest={AUDIO_MANIFEST}>{children}</SunoProvider>
}
// components/sfx-button.tsx
'use client'
import { useSuno, useUnlock } from '@joycostudio/suno/react'
import type { AudioManifest } from '@/lib/audio/manifest'
export function PlayClick() {
const suno = useSuno<AudioManifest>()
const { unlock, unlocked } = useUnlock()
const handleClick = async () => {
if (!unlocked) await unlock()
suno.get('click').play()
}
return <button onClick={handleClick}>Click me</button>
}
The first interaction both unlocks the audio context and plays the sound — one gesture covers both.
import { Suno } from '@joycostudio/suno'
const suno = new Suno({
manifest: {
click: { src: '/audio/click.ogg' },
ambient: { src: '/audio/ambient.ogg', loop: true },
},
})
document.querySelector('#start')!.addEventListener('click', async () => {
await suno.unlock()
await suno.loadAll()
suno.get('ambient').play()
})
unlock() must be called inside a user-gesture handler (click, keydown, touch) — the browser blocks audio until then.
suno.get('click').play() // each call spawns a new Voice — clicks layer cleanly
const voice = suno.get('ambient').play()
// later:
voice.stop()
Useful for voice-over lines or any "one at a time" source:
suno.get('vo-line-1').play({ exclusive: true })
import { Mixer } from '@joycostudio/suno'
const mixer = new Mixer({ suno, fadeOutDuration: 1, fadeInDuration: 1 })
mixer.stopByKey('ambient-day', { fadeOut: 2 })
mixer.play('ambient-night', { fadeIn: 2, loop: true })
In React, create the Mixer in a ref so it persists across renders:
const mixerRef = useRef<Mixer | null>(null)
if (!mixerRef.current) mixerRef.current = new Mixer({ suno })
useEffect(() => () => mixerRef.current?.dispose(), [])
Build the chain once, reuse the head node across many plays:
const ctx = suno.player.audioContext
const filter = ctx.createBiquadFilter()
filter.type = 'lowpass'
filter.frequency.value = 800
const fx = suno.effect(filter) // wires filter → masterOutput, returns filter
suno.get('click').play({ output: fx })
suno.get('hover').play({ output: fx })
For reusable effect modules (classes that wrap a node graph, expose a chain head, and are safe to declare at module scope alongside suno), follow the pattern in writing-effects.md. It covers SSR-safe construction, attach() / detach() lifecycle, buffered params, parallel topologies (dry/wet), and per-voice routing.
Affects every live voice and seeds new ones. Great for pause menus:
suno.setPlaybackRate(0.5) // tape-style slowmo (half speed, octave down)
suno.setPlaybackRate(1) // back to normal
voice.setVolume(v) — one specific voice.source.setVolume(v) — the per-source bus (scales every voice on that source).source.setDefaultVolume(v) — initial volume for newly-spawned voices.suno.setMasterVolume(v) — global master gain.They multiply: masterVolume × sourceBusVolume × voiceVolume.
useSuno<M>() — the Suno instance, typed by your manifest. Throws if no provider.useUnlock() → { unlock, unlocked }. Call unlock inside a user-gesture handler.useSource(key) → { source, isPlaying, voices, volume, loop, duration } | null. Reactive.useVoice(voice) → { state, isPlaying, currentTime, duration, volume, loop, playbackRate, effectivePlaybackRate }. Reactive snapshot of a single voice.usePlaying() → array of { key, definition, source, voice } for every live voice.useSunoState() → { state, isPlaying, masterVolume, playbackRate, unlocked }.All hooks use useSyncExternalStore — SSR-safe, no hydration mismatches.
play() behind a click/keydown. In React, use useUnlock and check unlocked before playing — or call unlock() inside the same handler as the play.new Suno() is safe on the server (the player lazy-inits). But touching suno.player.audioContext / masterOutput from server code throws. Put audio calls in client components or behind useEffect.ended or stop, the Voice is disposed. Methods become no-ops, but don't design flows that assume a Voice lives forever — subscribe to voice.on('ended', ...) or use useVoice for reactive state.SunoProvider disposes Suno on unmount. In dev, retaining voice refs across a remount will log "context closed" errors — recreate refs on the new provider.ended. Looping voices stay alive until you call .stop() — keep the reference, or use source.stopAll() / suno.stopAll() / mixer.stopByKey().lib/audio/
manifest.ts # AUDIO_MANIFEST + types
provider.tsx # <AudioProvider> mounting SunoProvider
use-sfx.ts # optional hook wrapping common plays (useSfx().click())
app/
layout.tsx # <AudioProvider> wraps children
A thin useSfx wrapper keeps call sites short:
// lib/audio/use-sfx.ts
'use client'
import { useSuno, useUnlock } from '@joycostudio/suno/react'
import type { AudioManifest } from './manifest'
export function useSfx() {
const suno = useSuno<AudioManifest>()
const { unlock, unlocked } = useUnlock()
const play = async (key: keyof AudioManifest) => {
if (!unlocked) await unlock()
suno.get(key).play()
}
return { play }
}
Then at the call site:
const { play } = useSfx()
<button onClick={() => play('click')}>OK</button>
as const and an exported type.SunoProvider / new Suno() instantiated exactly once at the appropriate scope.useEffect on mount).suno.player.audioContext.tools
Analyze a Chrome DevTools Performance trace JSON file for performance anomalies, producing a structured audit report with critical issues, warnings, metrics, timeline hotspots, and actionable recommendations.
development
Analyze a bye-thrash layout thrashing report array. Parses stack traces, identifies user-code functions causing forced reflows, locates the offending style-write → layout-read pairs in source files, and produces a structured fix-suggestion report.
development
Author or refactor a skill in this repo. Use when the user asks to "create a skill", "write a skill", "add a new skill", "document this as a skill", or to restructure an existing SKILL.md (split it up, slim it down, fix the frontmatter). Covers frontmatter conventions, file layout, and the rule for splitting deep reference material into linked docs instead of bloating SKILL.md.
data-ai
Diagnose and resolve git conflicts of any kind — merge, rebase, cherry-pick, stash, revert. Use this skill EVERY time conflicts appear during work, or whenever the user mentions merge conflicts, rebase conflicts, conflict markers, "both modified" files, a failed or conflicted git pull, or asks to "fix conflicts". Use it even when the resolution looks obvious — many conflicts are phantom artifacts of squash merges or rewritten upstream history, and the correct fix is a different git strategy (e.g. git rebase --onto), not editing conflict markers.