Skip to content

Refactor SDK into modular core/player architecture#82

Merged
maximmaxim345 merged 30 commits intomainfrom
claude/refactor-sendspin-modular-X87fx
Apr 20, 2026
Merged

Refactor SDK into modular core/player architecture#82
maximmaxim345 merged 30 commits intomainfrom
claude/refactor-sendspin-modular-X87fx

Conversation

@balloob
Copy link
Copy Markdown
Contributor

@balloob balloob commented Apr 12, 2026

Summary

Split the monolithic SDK into a composable architecture where the core protocol/decoding layer is separated from Web Audio playback. This enables:

  • Visualization apps to consume the raw PCM stream without playback overhead
  • Conformance tests to test protocol handling without needing Web Audio
  • External WebSocket management — pass in a pre-established socket instead of a URL
  • Configurable sync correction thresholds per correction mode

The SendspinPlayer public API is unchanged — existing consumers are unaffected.

Final structure

src/
  index.ts                           SendspinPlayer + public re-exports
  types.ts                           All shared types + CorrectionThresholds

  core/
    core.ts                          SendspinCore (protocol + decode coordinator)
    protocol-handler.ts              Slim message routing (342 lines, was 604)
    time-sync-manager.ts             NTP burst lifecycle (extracted)
    codec-support.ts                 Browser codec detection (extracted)
    state-manager.ts                 Observable state store
    time-filter.ts                   Kalman filter clock sync
    websocket-manager.ts             WebSocket + adopt() for external sockets

  audio/
    scheduler.ts                     Scheduling + sync correction (688 lines, was 1581)
    clock-source.ts                  Output timestamp validation state machine
    recorrection-monitor.ts          Drift detection with transient filtering
    output-latency-tracker.ts        EMA latency smoothing + persistence
    decoder.ts                       PCM/Opus/FLAC → Float32Array[]

New public exports

Export Purpose
SendspinPlayer Same API as before — drop-in replacement
SendspinCore Protocol + decode only — emits DecodedAudioChunk
SendspinDecoder Standalone audio decoder
AudioScheduler Standalone Web Audio scheduler
DecodedAudioChunk { samples: Float32Array[], sampleRate, serverTimeUs, generation }
CorrectionThresholds Per-mode sync correction tuning

Usage examples

Visualization app (tap into PCM stream):

const core = new SendspinCore({ baseUrl: "http://..." });
core.onAudioData = (chunk) => {
  renderWaveform(chunk.samples); // Float32Array[] per channel
};
await core.connect();

External WebSocket (no baseUrl needed):

const ws = new WebSocket("wss://my-proxy/sendspin");
const core = new SendspinCore({ webSocket: ws });
await core.connect();

Custom correction thresholds:

const player = new SendspinPlayer({
  baseUrl: "http://...",
  correctionThresholds: { sync: { resyncAboveMs: 400 } },
});

Changes

  • Monolith split: audio-processor.ts (2261 lines) → audio/decoder.ts + audio/scheduler.ts + 3 sub-modules
  • Protocol handler split: protocol-handler.ts (604 lines) → slim handler + time-sync-manager.ts + codec-support.ts
  • Type safety: WebSocketManager.send() typed as ClientMessage instead of any
  • Environment compat: window.setTimeoutglobalThis.setTimeout everywhere
  • External WebSocket: webSocket config option with WebSocketManager.adopt(); baseUrl now optional
  • Configurable thresholds: correctionThresholds config option with per-mode partial overrides

Test plan

  • Verify SendspinPlayer works identically to before (connect, play, volume, sync)
  • Verify SendspinCore standalone emits DecodedAudioChunk events
  • Verify external WebSocket pass-through works
  • Verify correction threshold overrides apply correctly
  • Build succeeds with tsc --noEmit

https://claude.ai/code/session_018UYYEXUZVuQ2Z4Texa7W6m

claude added 8 commits April 12, 2026 06:26
Begin refactoring sendspin-js into a core library (protocol + decoding)
and a player wrapper (Web Audio playback). This commit adds:

- DecodedAudioChunk, SendspinCoreConfig, StreamHandler interfaces to types.ts
- SendspinDecoder class in audio-decoder.ts: standalone PCM/Opus/FLAC
  decoder that produces raw Float32Array[] samples without Web Audio
  playback concerns

https://claude.ai/code/session_018UYYEXUZVuQ2Z4Texa7W6m
Split the monolithic SDK into a composable architecture:

- SendspinCore: Protocol + time sync + state + decoding layer.
  Produces raw PCM DecodedAudioChunk (Float32Array[] per channel)
  that visualization apps or conformance tests can consume directly.

- SendspinPlayer: Wraps Core + AudioScheduler. Maintains the same
  public API so existing consumers are unaffected.

- SendspinDecoder: Standalone audio decoder (PCM, Opus, FLAC) that
  produces Float32Array[] without any Web Audio playback dependency.
  Uses OfflineAudioContext for FLAC instead of a playback context.

- AudioScheduler: Web Audio scheduling, sync correction, volume,
  and output routing — extracted from the former AudioProcessor.

- StreamHandler interface: Decouples ProtocolHandler from audio
  implementation, enabling protocol-only testing.

File changes:
- New: core.ts, audio-decoder.ts, audio-scheduler.ts
- Modified: types.ts (DecodedAudioChunk, SendspinCoreConfig, StreamHandler),
  protocol-handler.ts (uses StreamHandler), index.ts (re-exports new modules)
- Deleted: audio-processor.ts (split into decoder + scheduler)

https://claude.ai/code/session_018UYYEXUZVuQ2Z4Texa7W6m
Add webSocket option to SendspinCoreConfig and SendspinPlayerConfig.
When provided, the core adopts the existing socket via
WebSocketManager.adopt() instead of creating one from baseUrl.
Auto-reconnect is disabled for externally-managed sockets.

This enables server-side proxying, conformance testing with mock
sockets, and scenarios where the WebSocket is managed externally.

https://claude.ai/code/session_018UYYEXUZVuQ2Z4Texa7W6m
baseUrl is no longer required if a pre-established WebSocket is passed
in. A clear error is thrown at connect() if neither is provided.

https://claude.ai/code/session_018UYYEXUZVuQ2Z4Texa7W6m
Organize modules into logical groups:
- src/core/ — protocol, state, websocket, time sync
- src/audio/ — decoder, scheduler

All import paths updated. No logic changes.

https://claude.ai/code/session_018UYYEXUZVuQ2Z4Texa7W6m
- WebSocketManager.send() now takes ClientMessage instead of any
- All window.setTimeout/setInterval replaced with globalThis.* for
  non-browser environment compatibility (Node, Workers, SSR)
- Timer handle types use ReturnType<typeof setTimeout> instead of number

https://claude.ai/code/session_018UYYEXUZVuQ2Z4Texa7W6m
Protocol handler extractions:
- codec-support.ts: getBrowserSupportedCodecs + getSupportedFormats
  as standalone pure functions
- time-sync-manager.ts: TimeSyncManager class with all NTP burst
  lifecycle logic (probes, timeouts, candidate selection)
- protocol-handler.ts slimmed from 604 to 342 lines

Audio scheduler sub-modules (created, integration next):
- clock-source.ts: Clock source selection + output timestamp validation
- recorrection-monitor.ts: Drift detection with transient filtering
- output-latency-tracker.ts: EMA smoothing + persistence

https://claude.ai/code/session_018UYYEXUZVuQ2Z4Texa7W6m
- Rewrite scheduler.ts (1581→688 lines) to delegate to:
  - ClockSource: output timestamp validation + clock selection
  - RecorrectionMonitor: drift detection with transient filtering
  - OutputLatencyTracker: EMA smoothing + persistence
- Add CorrectionThresholds interface and correctionThresholds config
  option to SendspinPlayerConfig for per-mode threshold overrides
- Default thresholds are deep-merged with user overrides

https://claude.ai/code/session_018UYYEXUZVuQ2Z4Texa7W6m
@balloob balloob marked this pull request as draft April 12, 2026 12:36
balloob pushed a commit that referenced this pull request Apr 12, 2026
Introduces Vitest test infrastructure to validate the PR #82 refactor.
Includes a mock Sendspin server (TypeScript/ws) for E2E protocol testing
and unit tests for the core components (TimeFilter, StateManager, Decoder).

- E2E: Full session lifecycle (handshake, time sync, streaming, commands, disconnect)
- E2E: External WebSocket adoption, volume/mute, sync delay, format updates
- Unit: Kalman filter time synchronization (offset, drift, reset, adaptive forgetting)
- Unit: State management (volume clamping, callbacks, server/group state merging)
- Unit: PCM decoder (16/24/32-bit, mono/stereo, generation filtering, edge cases)

https://claude.ai/code/session_011MZZ4bayCXYhwnrd9rY5dc
Port two upstream bug fixes into the modular Core/Scheduler architecture:

- #83 Reduce scheduling horizon on Cast: Add CAST_SCHEDULE_HORIZON_*
  constants and isCastRuntime parameter to AudioScheduler. Cast
  receivers now use shorter horizons (1.5s/1s/0.5s) to reduce
  stuttering.

- #84 Reduce playback gaps after disconnects: Add
  measureBufferedPlaybackRunwaySec() and refill scheduling to
  AudioScheduler. SendspinPlayer defers playback reset on disconnect
  until buffered audio is exhausted, using new onConnectionOpen/
  onConnectionClose events from Core.

https://claude.ai/code/session_018UYYEXUZVuQ2Z4Texa7W6m
@balloob balloob added the refactor This PR refactores Code label Apr 13, 2026
@balloob balloob marked this pull request as ready for review April 13, 2026 14:37
@balloob
Copy link
Copy Markdown
Contributor Author

balloob commented Apr 13, 2026

Validated and it works both in sendspin-js demo website + as part of sendspin party.

Port the Cast timestamp fix into the modular architecture:
- ClockSource gains disableTimestampPromotion() which forces the
  estimated clock and prevents getOutputTimestamp() promotion
- AudioScheduler calls it when isCastRuntime is true
- Status log shows "estimated(cast-disabled)" on Cast receivers

This avoids audible rate oscillations on Nest Hubs.

https://claude.ai/code/session_018UYYEXUZVuQ2Z4Texa7W6m
Stored, never read — only the audio scheduler consumes the flag.
getSmoothedUs advances the EMA and triggers persistence as side effects.
Refactor to capture it once instead.
clearBuffers previously called recorrectionMonitor.stop(), which preserved
_lastHardResyncAtMs and _hardResyncGraceUntilMs. Stale cooldown then blocked
an early hard resync on the next stream. Use fullReset() to wipe these too.
Consolidates four duplicate clamp expressions across core, protocol-handler,
and scheduler. Preserves the isFinite guard the scheduler already had.
Old handleBinaryMessage logged when an audio chunk arrived before an
AudioContext existed. The split decoder no longer owns a context, so the
warn moves to scheduler.handleDecodedChunk where the nullability is checked.
Three related fixes to the adopted-socket path:

- Throw synchronously when passed a CLOSING or CLOSED socket instead of
  silently no-op'ing — the caller almost certainly wants to know.
- Null out the old socket's handlers before calling close() so its late
  async onclose event cannot fire the newly-wired close handler.
- Return a Promise that resolves on open (or rejects on early close), so
  SendspinCore.connect() can await a CONNECTING adopted socket before
  the caller's next API call races the session.
Old setActiveAudioClockSource kicked the queue when promoting to timestamp
mid-stream. The split ClockSource.setActive only flipped _pendingCutover,
letting the next tick (up to 250ms later) pick up the cutover. Restore the
immediate kick via an onPromotion callback wired to scheduleQueueProcessing.
- SendspinCore onClose now clears the periodic state-update interval so
  it doesn't spam "WebSocket not connected" warnings every 5s for
  standalone Core consumers who have no cleanup path of their own.
- Add SendspinCore.resetPlaybackState() to reset isPlaying /
  currentStreamFormat without tearing down the connection.
- SendspinPlayer uses the new method instead of reaching into
  core._stateManager directly.
Was hardcoded to 2 channels, which decoded mono FLAC into stereo and
would silently drop channels beyond stereo. Use format.channels and
rebuild the cached context when it changes.
syncInfo.outputLatencyMs was reading a cached value set by the last
getSmoothedUs call, so it could lag up to a recorrection interval behind
the AudioContext. Read baseLatency + outputLatency directly. The now-unused
cache and accessor are removed.
AudioBufferQueueItem and StreamHandler are internal glue between
SendspinCore and the audio scheduler / protocol handler. Exporting them
via index.ts's export * shrinks future refactor freedom and leaks
implementation detail. Move to src/internal-types.ts, which is not
re-exported.
The two config interfaces duplicated ~12 fields with parallel docstrings
that had already started to drift. Make Player's config extend Core's, and
promote the richer docstrings (codec notes, syncDelay range, hardware-volume
semantics) to the Core base so both surfaces see them.
The 14 positional parameters were a maintainability trap: two adjacent
booleans (isAndroid, isCastRuntime, ownsAudioElement, useHardwareVolume)
meant a wrong-order call site could still typecheck. Take a named
AudioSchedulerOptions instead.
Add examples for the adopted-WebSocket path, correctionThresholds
overrides, and using SendspinCore standalone for decoded-PCM consumers.
Add an Advanced section with resyncAboveMs / deadbandBelowMs inputs that
feed into correctionThresholds on connect, persisted in localStorage.
Copy link
Copy Markdown
Member

@maximmaxim345 maximmaxim345 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! Thanks @balloob !
Tested on Chromium and Safari in through Music Assistant and the sample player, as well as on a Google Home mini.

@maximmaxim345 maximmaxim345 merged commit a4cff99 into main Apr 20, 2026
2 checks passed
@maximmaxim345 maximmaxim345 deleted the claude/refactor-sendspin-modular-X87fx branch April 20, 2026 09:27
@maximmaxim345 maximmaxim345 added the new-feature Request or implement a new feature label Apr 20, 2026
balloob pushed a commit that referenced this pull request Apr 20, 2026
Introduces Vitest test infrastructure to validate the PR #82 refactor.
Includes a mock Sendspin server (TypeScript/ws) for E2E protocol testing
and unit tests for the core components (TimeFilter, StateManager, Decoder).

- E2E: Full session lifecycle (handshake, time sync, streaming, commands, disconnect)
- E2E: External WebSocket adoption, volume/mute, sync delay, format updates
- Unit: Kalman filter time synchronization (offset, drift, reset, adaptive forgetting)
- Unit: State management (volume clamping, callbacks, server/group state merging)
- Unit: PCM decoder (16/24/32-bit, mono/stereo, generation filtering, edge cases)

https://claude.ai/code/session_011MZZ4bayCXYhwnrd9rY5dc
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

new-feature Request or implement a new feature refactor This PR refactores Code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants