A generative audio SDK for apps, games, and interactive software.
Backed by underscore.audio — the hosted Underscore backend powering this SDK.
Music as code, not files. Describe a sound, get a programmable synth your app drives at runtime — start it, stop it, and modulate its parameters in response to anything that happens in your code. Runs in any modern browser via WebAssembly (SuperCollider compiled to WASM).
Sign up and create your first synth at underscore.audio; the dashboard hands you a publishable API key and a composition ID you can paste into the example below.
Start with examples/hello-world/ — a single
HTML file with no npm, framework, or build step. Paste in a publishable
key and a public composition ID, serve it with COOP/COEP headers, and
click Play.
git clone https://github.com/underscore-audio/underscore-web-sdk
cd underscore-web-sdk/examples/hello-world
# fill in DEMO_KEY and DEMO_COMP in index.html, then:
npx http-server . -p 8080 \
--header "Cross-Origin-Opener-Policy: same-origin" \
--header "Cross-Origin-Embedder-Policy: require-corp"
# open http://localhost:8080The HTML file imports the SDK from a CDN and loads WASM from underscore.audio — nothing to install. The two headers are required by browsers for SharedArrayBuffer (which the WASM audio engine needs).
npm install @underscore-audio/sdk supersonic-scsynth supersonic-scsynth-coreThe fastest path is the install wizard, which handles the WASM setup and required server headers automatically:
npx @underscore-audio/wizard@latestThe wizard only does plumbing plus a demo play button. For product
integration (when to init(), generate vs load, secret-key proxy, mute,
latency patterns), see INTEGRATE.md — written for
coding agents and humans wiring Underscore into a real app.
import { Underscore } from "@underscore-audio/sdk";
const client = new Underscore({
apiKey: "us_pub_...", // publishable key — safe for browser code
wasmBaseUrl: "/supersonic/",
});
document.getElementById("play").addEventListener("click", async () => {
await client.init(); // MUST be inside a user gesture
const synth = await client.loadSynth("cmp_abc123");
await synth.play();
});wasmBaseUrl must point to the directory where you copied the
supersonic runtime files (see WASM setup below).
init() must be called from inside a user-gesture handler (click, tap,
keydown). Browser autoplay policy holds the AudioContext in a
suspended state until a real user gesture, and Underscore's audio
engine cannot start until the context is running. The SDK enforces
this with a 10-second watchdog: if init() is called outside a gesture
(or any time the AudioContext cannot transition to running), the
returned promise rejects with an AudioError ("Audio engine init() did
not complete within 10000ms...") and the SDK clears its internal init
state so a retry from inside a real gesture handler starts fresh.
Copy the WASM and audio worker files to your public directory:
npx underscore-sdk ./public/supersonicAdd these headers to your dev server and production server:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Vite
// vite.config.ts
export default defineConfig({
server: {
headers: {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
},
},
optimizeDeps: { exclude: ["@underscore-audio/sdk", "supersonic-scsynth"] },
});Next.js
// next.config.js
module.exports = {
async headers() {
return [
{
source: "/:path*",
headers: [
{ key: "Cross-Origin-Opener-Policy", value: "same-origin" },
{ key: "Cross-Origin-Embedder-Policy", value: "require-corp" },
],
},
];
},
};Generation consumes LLM credits and requires a secret key that must never ship to the browser. The SDK splits generation into two calls so each half runs in the right environment.
On your server (holds the secret key):
import { Underscore } from "@underscore-audio/sdk";
const server = new Underscore({ apiKey: process.env.UNDERSCORE_SECRET_KEY });
app.post("/generate", async (req, res) => {
const { jobId, streamUrl } = await server.startGeneration(
req.body.compositionId,
req.body.description,
{
kind: "program", // optional: "synth" (default) | "program"
complexity: "rich", // optional: "fast" | "balanced" | "rich"
model: "claude-opus-5", // optional model pin
}
);
res.json({ streamUrl });
});The optional third argument tunes the generation job: kind selects a
single synth (default) or a multi-synth program; complexity trades
speed against musical richness ("fast" for latency-sensitive paths
like gameplay, "rich" for maximum quality); and model pins a
specific catalogue model for callers that need one. Omit knobs to get
the default single-shot synth behavior. Prefer complexity, which is
stable across model generations. Accepted model values are:
- Anthropic:
"claude-sonnet-5","claude-opus-5","claude-fable-5" - OpenAI:
"gpt-5.6-luna","gpt-5.6-terra","gpt-5.6-sol" - xAI:
"grok-4.5"
In the browser (publishable key, playback only):
for await (const event of client.subscribeToGeneration(streamUrl, { compositionId })) {
switch (event.type) {
case "thinking":
/* event.content: partial LLM reasoning */ break;
case "progress":
/* event.content: phase label, e.g. "compiling" */ break;
case "code":
/* event.content: streaming SuperCollider code chunk */ break;
case "ready":
await event.program?.play(); // kind: "program"
await event.synth?.play(); // kind: "synth" (default)
break;
case "error":
/* event.error: failure message */ break;
case "raw":
/* event.raw: unmapped server payload (escape hatch) */ break;
}
}The streamUrl contains an unguessable jobId — the browser
subscribes directly to the Underscore API without credentials. When
you pass a compositionId to subscribeToGeneration, the SDK
auto-loads the finished synth on the terminal ready event and
attaches it as event.synth so you can immediately call .play().
To cancel a subscription early (effect cleanup, navigation, watchdog
timeout), pass an AbortSignal. Aborting closes the SSE socket and
ends the generator:
const controller = new AbortController();
const iter = client.subscribeToGeneration(streamUrl, {
compositionId,
signal: controller.signal,
});
// ...
controller.abort(); // closes the stream and ends the for-awaitSee examples/backend-proxy/ for the
full working example.
interface UnderscoreConfig {
apiKey: string; // "us_pub_..." (browser) or "us_sec_..." (server)
baseUrl?: string; // defaults to "https://underscore.audio"
wasmBaseUrl?: string; // defaults to "/supersonic/"; only consumed on init()/loadSynth()
workerBaseUrl?: string; // defaults to wasmBaseUrl + "workers/"
logLevel?: "debug" | "info" | "warn" | "error" | "none"; // defaults to "none"
}wasmBaseUrl and workerBaseUrl are only read when the audio engine
starts. Server-side Node usage (e.g. calling startGeneration from a
backend proxy) never touches the audio engine, so they can be omitted
there.
| Method | Signature | Returns / Throws |
|---|---|---|
init() |
() => Promise<void> |
Resolves once the WASM audio engine is running. Rejects with AudioError if not called from a user gesture (10s watchdog). |
isInitialized() |
() => boolean |
true once init() has resolved. |
loadSynth(compositionId, synthName?) |
(string, string?) => Promise<Synth> |
Resolves to a playable Synth. Defaults to the latest synth in the composition when synthName is omitted. Throws ApiError (HTTP), ValidationError (schema), AudioError (engine), or SynthError (no synths). |
listSynths(compositionId) |
(string) => Promise<SynthSummary[]> |
All synths in the composition. Throws ApiError, ValidationError. |
getSynth(compositionId, synthName) |
(string, string) => Promise<SynthMetadata> |
Full metadata including samples + synthdef URL. |
getComposition(compositionId) |
(string) => Promise<Composition> |
Composition metadata, including synthCount and programCount. |
listPrograms(compositionId) |
(string) => Promise<ProgramSummary[]> |
All programs (multi-synth pieces) in the composition: title, bpm, duration, sections. Throws ApiError, ValidationError. |
getProgramManifest(compositionId, programName) |
(string, string) => Promise<ProgramManifest> |
The full program manifest (setup + event timeline). Prefer listPrograms when you only need display metadata. |
loadProgram(compositionId, programName?) |
(string, string?) => Promise<Program> |
Resolves to a playable Program. Fetches the manifest and every SynthDef it names, and boots the audio engine (call from a user gesture). Defaults to the latest program when programName is omitted. Throws ApiError, ValidationError, AudioError, or SynthError. |
createComposition(options?) |
(CreateCompositionOptions?) => Promise<CreateCompositionResponse> |
Server-only (requires secret key). |
startGeneration(compositionId, description, options?) |
(string, string, { kind?; complexity?; model? }?) => Promise<{ jobId, streamUrl }> |
Server-only. Kicks off a generation job; returns the unguessable jobId and a relative streamUrl to forward to the browser. options.kind ("synth" | "program") selects the artifact; options.complexity trades speed against richness; options.model pins a catalogue model (Anthropic Opus/Sonnet/Fable 5, GPT-5.6 Luna/Terra/Sol, Grok 4.5). Throws ApiError. |
subscribeToGeneration(streamUrl, options?) |
(string, { compositionId?: string; signal?: AbortSignal }?) => AsyncGenerator<GenerationEvent & { synth?; program? }> |
Browser-only. Yields events from the SSE stream. When options.compositionId is provided, auto-loads the finished synth or program on ready. options.signal cancels the subscription. |
generate(compositionId, description, options?) |
(string, string, { kind?; complexity?; model? }?) => AsyncGenerator<GenerationEvent & { synth?; program? }> |
Legacy combined flow. Only safe in trusted environments that can use a secret key AND have an EventSource global (Node CLI w/ polyfill, Electron). Browser apps must use the two-call pattern above. |
setMasterVolume(value) |
(number) => void |
Output bus gain in [0, 2]. Out-of-range values clamp with a console warning. Non-finite values throw ValidationError. Safe to call before init() (cached and applied on init). |
getMasterVolume() |
() => number |
Current (clamped) master gain. Defaults to 1.0. |
audioContext (getter) |
AudioContext | null |
Underlying AudioContext once initialized; null before. Useful for advanced routing. |
shutdown() is not currently part of the public client API; let the
client be garbage-collected when you're done with it.
class Synth {
// identity
readonly compositionId: string;
readonly name: string;
readonly description: string;
readonly params: ParamMetadata[];
readonly samples: SampleMetadata[] | undefined;
// playback
play(): Promise<void>; // throws SynthError if not loaded
stop(): void;
isPlaying(): boolean;
// parameters (clamped to each param's [min, max])
setParam(name: string, value: number): void;
setParams(params: Record<string, number>): void;
getParam(name: string): number | undefined;
getAllParams(): Record<string, number>;
resetParams(): void;
// crossfade between synths (both run during the transition)
crossfadeIn(durationSec?: number): Promise<void>; // default 3s
isCrossfading(): boolean;
// observe play/stop/param state
subscribe(listener: (state: SynthState) => void): () => void;
}ParamMetadata (returned in synth.params) is the contract you
build UI around:
interface ParamMetadata {
name: string; // OSC arg name
type: ParamType; // "freq" | "amp" | "time" | "rate" | ... (UI hint)
default: number;
min: number;
max: number;
scale?: ParamScale; // "linear" (default) | "log" | "exp" | ...
unit?: string; // "Hz", "ms", "dB", ...
description: string;
}A program is a complete timed piece: several SynthDefs, a routing
graph, and a beat-stamped event timeline, captured as a manifest.
Where a Synth is one instrument you play and tweak, a Program is
an arrangement you transport through.
class Program {
// identity / metadata (from the manifest)
readonly compositionId: string;
readonly name: string;
readonly title: string;
readonly description: string;
readonly bpm: number;
readonly durationBeats: number;
readonly durationSec: number;
readonly sections: ProgramSection[]; // { name, beat }
// transport
play(atBeat?: number): Promise<void>;
stop(): Promise<void>;
seek(beat: number): Promise<void>; // clamps to [0, durationBeats]
seekToSection(index: number): Promise<void>; // throws SynthError on bad index
isPlaying(): boolean;
// observe progress (~10 Hz while playing; immediate snapshot on subscribe)
subscribe(listener: (progress: ProgramProgress) => void): () => void;
}
interface ProgramProgress {
state: "idle" | "playing";
beat: number;
seconds: number;
progress: number; // 0..1
durationBeats: number;
durationSec: number;
sectionIndex: number; // -1 before the first section
sectionName: string | null;
sectionCount: number;
}const program = await underscore.loadProgram(COMPOSITION_ID); // latest program
await program.play();
const unsubscribe = program.subscribe((p) => {
bar.style.width = `${p.progress * 100}%`;
label.textContent = p.sectionName ?? program.title;
});
await program.seekToSection(2); // jump to the third section
await program.stop();
unsubscribe();Seeking is stateful, not just a jump: the SDK replays the cumulative control state up to the target beat before re-anchoring the timeline, so entering mid-piece sounds the way it would if the piece had played from the start.
Playback is exclusive per client: starting a program stops any playing
synth or other program, and calling synth.play() stops a running
program. Nothing double-plays.
subscribeToGeneration yields a discriminated union; check
event.type and read the field documented for that variant.
type |
Field | Meaning |
|---|---|---|
thinking |
content (partial text) |
LLM reasoning chunk. |
progress |
content (phase label) |
Phase/status change, e.g. "compiling", "Created synth: bassDrone". |
code |
content (partial text) |
Streaming SuperCollider code chunk. Useful for live previews. |
ready |
synthName, optional synth |
Generation complete. When the SDK was given a compositionId, synth is a loaded Synth ready to .play(). |
error |
error (string) |
Generation failed, declined, or the SSE connection dropped. |
raw |
raw (Record<string, unknown>) |
Unmapped server event. Escape hatch for power users; shape is unversioned. |
import {
UnderscoreError, // base class
ApiError, // .status: number (HTTP code)
AudioError, // WASM/WebAudio failures, including the 10s init watchdog
SynthError, // synth playback / lifecycle errors
ValidationError, // .issues: unknown[] (Zod issues from API responses)
} from "@underscore-audio/sdk";| Class | Thrown when |
|---|---|
ApiError |
HTTP request to the Underscore API fails. Carries status. |
AudioError |
Audio engine cannot start (e.g. autoplay-suspended AudioContext past the 10s watchdog), missing sample URL, or init() was never called before play(). |
SynthError |
play() called on an unloaded synth, loadSynth()/loadProgram() ran against a composition with none, or a program seek targeted an invalid section. |
ValidationError |
API response failed Zod schema validation (most often a server contract drift). Carries issues. |
| Type | Prefix | Where | Scopes |
|---|---|---|---|
| Publishable | us_pub_ |
Browser / client | synth:read |
| Secret | us_sec_ |
Server only | synth:read, synth:generate |
Issued from the underscore.audio dashboard on signup.
Requires SharedArrayBuffer, AudioWorklet, and WebAssembly: Chrome 80+, Firefox 79+, Safari 15.4+, Edge 80+, iOS Safari 15.4+. SharedArrayBuffer needs cross-origin isolation, which is why COOP/COEP are mandatory.
| Symptom | Fix |
|---|---|
AudioError: Audio engine init() did not complete within 10000ms... |
Call client.init() from inside a click handler or other user gesture. |
Audio not initialized on play() |
await client.init() before loadSynth() / play(). |
| WASM not loading | Run npx underscore-sdk ./public/supersonic; confirm COOP/COEP headers are set. |
| No sound | Check client.isInitialized(), synth.isPlaying(), and getMasterVolume(). |
Composition not found |
Verify cmp_... format and that the composition's visibility is public or unlisted. |
ValidationError from any client method |
Your installed SDK is older than the API contract; update @underscore-audio/sdk. |
Early SDK. Limited docs. Generation quality varies by prompt. Best
current use case is ambient, reactive, and interactive audio — not
finished pop songs. The generation API is stable; the parameter
control surface and the raw generation-event payload may grow.
You don't have to use the hosted Underscore backend. The SDK speaks a documented HTTP+SSE contract, so a self-hosted server can stand in for underscore.audio as long as it implements the contract below. This section is a pointer, not a full spec — open an issue if you want the long-form self-host documentation.
Minimum viable contract:
- Auth header. All requests carry
Underscore-API-Key: <key>. Publishable keys (us_pub_*) getsynth:read; secret keys (us_sec_*) additionally getsynth:generate. GET /api/v1/compositions/:compositionId— returns composition metadata (Compositionshape).GET /api/v1/compositions/:compositionId/synths— returns{ synths: SynthSummary[] }.GET /api/v1/compositions/:compositionId/synths/:synthName— returnsSynthMetadata, including asynthdefUrland any signed sample URLs the SDK should fetch.GET /api/v1/compositions/:compositionId/synths/:synthName/synthdef— returns the compiled synthdef as raw bytes.POST /api/v1/compositions/:compositionId/generate(secret key required) — body{ description, complexity?, model? }, returns{ jobId, streamUrl }.streamUrlis the relative path the browser will subscribe to.- SSE stream at
streamUrl— emits the server-side event types the SDK maps into theGenerationEventunion (thinking,phase_change,status,repair_started,code,synth_created,complete,error).jobIdis the only auth — it is treated as a capability token, so the URL must be unguessable.
The TypeScript types in src/types.ts and the Zod schemas in
src/schemas.ts are the canonical contract.
npm install
npm run build
npm test # mocked unit + integration suite (no network)
npm run test:watch
npm run test:coverage
npm run test:live # exercises the SDK against a real Underscore API
npm run lintThe live test suite is configured entirely through environment variables and skips cleanly when credentials are absent. See CONTRIBUTING.md for the full variable list and the maintainer-only CI live-test setup.
A Makefile is provided as a parallel surface — handy if you work
across multiple repos that share the same conventional verbs:
make setup # npm install (root + packages/wizard)
make build # build SDK and wizard
make test # SDK mocked + wizard mocked + live (live skips without creds)
make lint # lint SDK and wizard
make fmt # prettier --write .
make clean # remove dist/ and packages/wizard/dist/npm is the canonical surface for external contributors; the make
targets just forward to the same scripts and keep day-to-day commands
identical across sibling projects.
See CONTRIBUTING.md.