TypeScript client library implementing the Sendspin Protocol for clock-synchronized audio streaming.
See the SDK website to see Sendspin JS in action: https://sendspin.github.io/sendspin-js/
import { SendspinPlayer } from '@sendspin/sendspin-js';
const player = new SendspinPlayer({
baseUrl: 'http://your-server:8095',
clientName: 'My Web Player',
productName: 'My App',
// Optional: "sync" (default), "quality" (no pitch shifts; not recommended for bad networks),
// or "quality-local" (best for unsynced playback)
correctionMode: 'sync',
onStateChange: (state) => {
// Local player state
console.log('Playing:', state.isPlaying);
console.log('Volume:', state.volume, 'Muted:', state.muted);
// Server state (metadata, controller info)
if (state.serverState?.metadata) {
const meta = state.serverState.metadata;
console.log('Track:', meta.title, '-', meta.artist);
}
// Group state (playback state, group info)
if (state.groupState) {
console.log('Group:', state.groupState.group_name);
console.log('Playback:', state.groupState.playback_state);
}
}
});
const connectButton = document.querySelector<HTMLButtonElement>('#connect')!;
connectButton.addEventListener('click', async () => {
// Keep this as the first awaited work in the click/tap handler.
await player.unlock();
await player.connect();
});
// Local volume control (affects this player only)
player.setVolume(80);
player.setMuted(false);
// Send commands to server (controls the source)
player.sendCommand('play');
player.sendCommand('pause');
player.sendCommand('stop');
player.sendCommand('next');
player.sendCommand('previous');
player.sendCommand('volume', { volume: 50 });
player.sendCommand('mute', { mute: true });
player.sendCommand('shuffle');
player.sendCommand('unshuffle');
player.sendCommand('repeat_off');
player.sendCommand('repeat_one');
player.sendCommand('repeat_all');
player.sendCommand('switch'); // Switch group
// Disconnect with reason (optional)
player.disconnect('user_request');Call unlock() directly from a click or tap handler, before any other awaited
work, so the browser can initialize or resume audio during the user gesture.
Provide an already-open (or CONNECTING) WebSocket via webSocket to let the
player adopt it instead of creating a new one. Useful when the connection is
managed by a surrounding app framework. Auto-reconnect is disabled for adopted
sockets.
const ws = new WebSocket('ws://your-server:8095/sendspin');
const player = new SendspinPlayer({
clientName: 'My Player',
webSocket: ws,
});
await player.unlock();
await player.connect();Built-in auto-reconnect uses exponential backoff (1s → 15s, unlimited
attempts). Override the bounds, cap the retry count, or hook callbacks to
drive UI and fatal-error paths via reconnect.
const player = new SendspinPlayer({
baseUrl: 'http://your-server:8095',
reconnect: {
baseDelayMs: 1000,
maxDelayMs: 15000,
maxAttempts: 7,
onReconnecting: (attempt) => console.log(`Reconnecting (attempt ${attempt})`),
onReconnected: () => console.log('Reconnected'),
onExhausted: () => console.log('Giving up'),
},
});Reconnection only applies to connections opened via baseUrl; adopted
sockets (webSocket) never auto-reconnect.
Override the per-mode thresholds that control when/how the scheduler corrects drift. Unspecified fields keep their defaults.
const player = new SendspinPlayer({
baseUrl: 'http://your-server:8095',
correctionMode: 'sync',
correctionThresholds: {
sync: {
resyncAboveMs: 400, // tolerate more drift before hard resync
deadbandBelowMs: 2, // ignore errors under 2ms
},
},
});Report the startup lead time and ongoing jitter buffer the player needs to the
server via client/state. Lower values mean lower latency at the risk of
underruns. Defaults are requiredLeadTimeMs: 250 and minBufferMs: 250.
const player = new SendspinPlayer({
baseUrl: 'http://your-server:8095',
requiredLeadTimeMs: 250, // startup warmup (codec init, decode, DAC)
minBufferMs: 250, // ongoing buffer to absorb network jitter
});Both can be updated at runtime, e.g. after measuring real lead time post-warmup or on a link-type change. Debounce updates so transient fluctuations don't churn server-side timing.
player.setRequiredLeadTimeMs(300);
player.setMinBufferMs(1500);Every connection is encrypted (Noise KKpsk2). By default the SDK connects with an unpaired (Sentinel-PSK) identity; pair with a server to upgrade to a trusted, per-server long-term PSK.
const player = new SendspinPlayer({
baseUrl: 'http://your-server:8095',
suite: 'chacha', // "chacha" (default) or "aesgcm"
unpairedAccess: true, // admit unpaired playback; default true (see note below)
longTermPsks: [
{ psk: 'base64url-psk', serverId: 'optional-server-id' },
],
onPairing: (event, detail) => {
// event: "started" | "finalized" | "aborted"
console.log('Pairing:', event, detail);
},
// Dynamic PIN pairing: show the derived PIN to the operator (null = hide).
onPairingPin: (pin) => showPinDialog(pin),
minPinLength: 6, // shortest dynamic PIN this client accepts (4-12)
// Static PIN pairing: this device's fixed 8-digit PIN.
staticPin: '31415926',
});
await player.connect();Unpaired playback authenticates with the well-known Sentinel PSK, so it is
exposed to an active man-in-the-middle. While it's on by default, you can set unpairedAccess: false to require pairing before any playback.
The SDK supports all three pairing methods from the spec:
- Pairing PSK (always available): transfer the client-bound pairing token.
- Dynamic PIN (enabled by
onPairingPin): the server starts pairing, the SDK derives a one-time PIN and passes it toonPairingPinfor display; the operator enters it into the server. - Static PIN (enabled by
staticPin): the operator enters this device's fixed 8-digit PIN into the server, then makes a local gesture that callsplayer.openPairingWindow()(window lasts ~5 minutes, one attempt).
console.log('Client ID:', player.clientId); // 43-char base64url pubkey
console.log('Pairing token:', player.pairingToken); // spec version 0, or null without storage
// Rotate the Pairing PSK (e.g. if it may have leaked)
const newPsk = player.rotatePairingPsk();
player.openPairingWindow(); // static PIN: operator gesture
player.cancelPairing(); // abort an in-progress attempt
player.isPairingLockedOut('dynamic_pin'); // terminal lockout after 10 failures
player.clearPairingLockout('dynamic_pin'); // local operator action that exits lockoutplayer.pairingToken is the version 0 token defined by the current specification. Music Assistant installations using aiosendspin 7.0.0 do not accept the current token format; use PIN pairing until the backend supports version 0.
Identity and pairing require storage (defaults to localStorage); without
it, clientId is still generated per session but pairingPsk,
pairingToken, and rotatePairingPsk() return null.
Apps that key their own state on the client id can read it before a player exists. A player built afterwards on the same storage adopts this identity.
import { loadSendspinClientIdentity } from '@sendspin/sendspin-js';
const { clientId, pairingPsk, pairingToken } = loadSendspinClientIdentity();Apps that need the decoded PCM stream (e.g. visualizers) can use
SendspinCore on its own and skip the playback layer. SendspinCore emits
DecodedAudioChunk events; AudioScheduler is the Web Audio consumer that
SendspinPlayer wires for you.
import { SendspinCore } from '@sendspin/sendspin-js';
const core = new SendspinCore({
baseUrl: 'http://your-server:8095',
});
core.onAudioData = (chunk) => {
// chunk.samples: Float32Array per channel
// chunk.sampleRate, chunk.serverTimeUs, chunk.generation
};
await core.connect();yarn dev-server
Then browse to http://localhost:6001
The E2E tests run directly against
aiosendspin. Bootstrap the .venv
once:
./scripts/setup.sh
Then:
yarn test # unit + E2E
yarn test:watch # watch mode
To run a single suite, pass the path: npx vitest run tests/unit or
npx vitest run tests/e2e.
