Refactor SDK into modular core/player architecture#82
Merged
maximmaxim345 merged 30 commits intomainfrom Apr 20, 2026
Merged
Conversation
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
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
Contributor
Author
|
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
5 tasks
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.
maximmaxim345
approved these changes
Apr 20, 2026
Member
maximmaxim345
left a comment
There was a problem hiding this comment.
Nice! Thanks @balloob !
Tested on Chromium and Safari in through Music Assistant and the sample player, as well as on a Google Home mini.
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Split the monolithic SDK into a composable architecture where the core protocol/decoding layer is separated from Web Audio playback. This enables:
The
SendspinPlayerpublic API is unchanged — existing consumers are unaffected.Final structure
New public exports
SendspinPlayerSendspinCoreDecodedAudioChunkSendspinDecoderAudioSchedulerDecodedAudioChunk{ samples: Float32Array[], sampleRate, serverTimeUs, generation }CorrectionThresholdsUsage examples
Visualization app (tap into PCM stream):
External WebSocket (no baseUrl needed):
Custom correction thresholds:
Changes
audio-processor.ts(2261 lines) →audio/decoder.ts+audio/scheduler.ts+ 3 sub-modulesprotocol-handler.ts(604 lines) → slim handler +time-sync-manager.ts+codec-support.tsWebSocketManager.send()typed asClientMessageinstead ofanywindow.setTimeout→globalThis.setTimeouteverywherewebSocketconfig option withWebSocketManager.adopt();baseUrlnow optionalcorrectionThresholdsconfig option with per-mode partial overridesTest plan
SendspinPlayerworks identically to before (connect, play, volume, sync)SendspinCorestandalone emitsDecodedAudioChunkeventstsc --noEmithttps://claude.ai/code/session_018UYYEXUZVuQ2Z4Texa7W6m