Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
79fcaf6
WIP: Add modular architecture types and audio decoder
claude Apr 12, 2026
645d621
Refactor into modular core/player architecture
claude Apr 12, 2026
1862e15
Support passing in an external WebSocket connection
claude Apr 12, 2026
0cda6b0
Make baseUrl optional when webSocket is provided
claude Apr 12, 2026
e57b175
Move source files into core/ and audio/ folders
claude Apr 12, 2026
acd47e6
Fix send() typing and replace window.* with globalThis.*
claude Apr 12, 2026
7b0ffa8
Extract sub-modules from protocol-handler and scheduler
claude Apr 12, 2026
d18823c
Integrate scheduler sub-modules and add configurable thresholds
claude Apr 12, 2026
538419a
Merge main (v3.0.2) and port bug fixes into modular architecture
claude Apr 13, 2026
ee7689e
Merge main and port #85: disable output timestamps on Cast
claude Apr 13, 2026
f4f8366
Merge main and port changes
maximmaxim345 Apr 15, 2026
e427b8c
Format
maximmaxim345 Apr 15, 2026
b7a5a5a
refactor: drop dead useOutputLatencyCompensation from ProtocolHandler
maximmaxim345 Apr 20, 2026
53f0905
refactor: localize wsUrl in SendspinCore.connect
maximmaxim345 Apr 20, 2026
bb62fb1
fix: double getSmoothedUs call in `emitStatusLog`
maximmaxim345 Apr 20, 2026
e3f288f
fix: reset hard-resync cooldown across stream boundaries
maximmaxim345 Apr 20, 2026
f8502a0
refactor: extract shared clampSyncDelayMs helper
maximmaxim345 Apr 20, 2026
1135d64
fix: restore audio-context-missing diagnostic in scheduler
maximmaxim345 Apr 20, 2026
bfb9b09
fix: tighten WebSocketManager.adopt contract
maximmaxim345 Apr 20, 2026
3487dc2
fix: kick audio queue on clock-source promotion to timestamp
maximmaxim345 Apr 20, 2026
d697455
fix: clean up playback state on transport close via Core API
maximmaxim345 Apr 20, 2026
9c91363
fix: derive FLAC OfflineAudioContext channel count from stream format
maximmaxim345 Apr 20, 2026
0f1455f
fix: report live output latency in syncInfo
maximmaxim345 Apr 20, 2026
ad09e82
refactor: encapsulate RecorrectionMonitor.minScheduleTimeSec behind a…
maximmaxim345 Apr 20, 2026
bf78a01
refactor: move internal plumbing types out of public types.ts
maximmaxim345 Apr 20, 2026
02d2d2b
refactor: deduplicate player and core config via extends
maximmaxim345 Apr 20, 2026
9f15ed2
refactor: convert AudioScheduler constructor to options object
maximmaxim345 Apr 20, 2026
f7d18b8
docs: document new SDK surfaces in README
maximmaxim345 Apr 20, 2026
9ac8e3d
feat(sample-player): expose correctionThresholds tuning
maximmaxim345 Apr 20, 2026
42c8e28
fix: clear hard-resync cooldown on mid-stream playback reset
maximmaxim345 Apr 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,65 @@ player.sendCommand('switch'); // Switch group
player.disconnect('user_request');
```

## Advanced configuration

### Bring your own WebSocket

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.

```typescript
const ws = new WebSocket('ws://your-server:8095/sendspin');
const player = new SendspinPlayer({
playerId: 'my-player',
clientName: 'My Player',
webSocket: ws,
});
await player.connect();
```

### Tuning correction thresholds

Override the per-mode thresholds that control when/how the scheduler corrects
drift. Unspecified fields keep their defaults.

```typescript
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
},
},
});
```

### Core + scheduler as separate layers

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.

```typescript
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();
```

## Local development

```
Expand Down
31 changes: 31 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,37 @@ <h2>Local Controls</h2>
so it may drift vs. other devices.
</small>
</div>
<details class="form-group">
<summary>Advanced: correction thresholds</summary>
<p>
<small
>Override per-mode tuning for the active correction mode.
Applied on connect — reconnect to apply changes.</small
>
</p>
<div class="form-group">
<label for="resync-threshold">Resync above (ms)</label>
<input
type="number"
id="resync-threshold"
placeholder="default"
min="0"
step="1"
/>
<small>Hard resync when smoothed sync error exceeds this.</small>
</div>
<div class="form-group">
<label for="deadband-threshold">Deadband below (ms)</label>
<input
type="number"
id="deadband-threshold"
placeholder="default"
min="0"
step="0.5"
/>
<small>Ignore errors smaller than this — no correction.</small>
</div>
</details>
</section>

<!-- Status Section -->
Expand Down
55 changes: 55 additions & 0 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ const STORAGE_KEYS = {
MUTED: "sendspin-muted",
SYNC_DELAY: "sendspin-sync-delay",
CORRECTION_MODE: "sendspin-correction-mode",
RESYNC_THRESHOLD: "sendspin-resync-threshold",
DEADBAND_THRESHOLD: "sendspin-deadband-threshold",
};

// DOM Elements
Expand All @@ -39,6 +41,8 @@ const muteIcon = document.getElementById("mute-icon");
const syncDelayInput = document.getElementById("sync-delay");
const applySyncDelayBtn = document.getElementById("apply-sync-delay");
const correctionModeSelect = document.getElementById("correction-mode");
const resyncThresholdInput = document.getElementById("resync-threshold");
const deadbandThresholdInput = document.getElementById("deadband-threshold");
const groupVolumeSlider = document.getElementById("group-volume-slider");
const groupVolumeValue = document.getElementById("group-volume-value");
const groupMuteBtn = document.getElementById("group-mute-btn");
Expand Down Expand Up @@ -426,6 +430,20 @@ function loadSettings() {
if (savedCorrectionMode !== null) {
correctionModeSelect.value = savedCorrectionMode;
}

const savedResyncThreshold = localStorage.getItem(
STORAGE_KEYS.RESYNC_THRESHOLD,
);
if (savedResyncThreshold !== null && resyncThresholdInput) {
resyncThresholdInput.value = savedResyncThreshold;
}

const savedDeadbandThreshold = localStorage.getItem(
STORAGE_KEYS.DEADBAND_THRESHOLD,
);
if (savedDeadbandThreshold !== null && deadbandThresholdInput) {
deadbandThresholdInput.value = savedDeadbandThreshold;
}
}

/**
Expand Down Expand Up @@ -456,6 +474,28 @@ function sanitizeSyncDelay(delay) {
return Math.max(0, Math.min(5000, Math.round(delay)));
}

/**
* Build a correctionThresholds override from the Advanced inputs.
* Returns undefined when no override is set for the current mode.
*/
function buildCorrectionThresholds(mode) {
const overrides = {};
const resyncRaw = resyncThresholdInput?.value;
const deadbandRaw = deadbandThresholdInput?.value;
const resync = resyncRaw ? parseFloat(resyncRaw) : NaN;
const deadband = deadbandRaw ? parseFloat(deadbandRaw) : NaN;
if (Number.isFinite(resync) && resync >= 0) {
overrides.resyncAboveMs = resync;
}
if (Number.isFinite(deadband) && deadband >= 0) {
overrides.deadbandBelowMs = deadband;
}
if (Object.keys(overrides).length === 0) {
return undefined;
}
return { [mode]: overrides };
}

/**
* Save correction mode to localStorage
*/
Expand Down Expand Up @@ -518,12 +558,15 @@ async function connect() {
const savedCorrectionMode =
localStorage.getItem(STORAGE_KEYS.CORRECTION_MODE) || "sync";

const correctionThresholds = buildCorrectionThresholds(savedCorrectionMode);

player = new SendspinPlayer({
playerId: getPlayerId(),
baseUrl: serverUrl,
clientName: "Sendspin Sample Player",
syncDelay: sanitizedSyncDelay,
correctionMode: savedCorrectionMode,
correctionThresholds,
onStateChange,
});

Expand Down Expand Up @@ -684,6 +727,18 @@ function init() {
muteBtn.addEventListener("click", toggleMute);
applySyncDelayBtn.addEventListener("click", applySyncDelay);
correctionModeSelect.addEventListener("change", applyCorrectionMode);
resyncThresholdInput?.addEventListener("change", () => {
localStorage.setItem(
STORAGE_KEYS.RESYNC_THRESHOLD,
resyncThresholdInput.value,
);
});
deadbandThresholdInput?.addEventListener("change", () => {
localStorage.setItem(
STORAGE_KEYS.DEADBAND_THRESHOLD,
deadbandThresholdInput.value,
);
});
groupVolumeSlider.addEventListener("input", () => {
player.sendCommand("volume", {
volume: parseInt(groupVolumeSlider.value, 10),
Expand Down
Loading
Loading