Skip to content

Sendspin compliance update#6

Merged
balloob merged 67 commits intoSendspin:mainfrom
teancom:feat/client-compliance
Apr 15, 2026
Merged

Sendspin compliance update#6
balloob merged 67 commits intoSendspin:mainfrom
teancom:feat/client-compliance

Conversation

@teancom
Copy link
Copy Markdown
Contributor

@teancom teancom commented Apr 15, 2026

Updated the library to be a conformant Sendspin client. Added tests and documentation. Passes all of the tests in the conformance repo and extensively tested manually with a Bruce Springsteen song. It's... a lot! I had Claude write up a summary of the changes across the branch:

This branch brings SendspinKit from a rough prototype to a spec-compliant, production-hardened client library. The work spans the entire stack — audio pipeline, sync engine, networking, public API, and developer ergonomics. The audio pipeline was rebuilt from the ground up. Playback was broken at the start of this branch; it now works end-to-end with FLAC, Opus, and PCM codecs. A Kalman time filter (ported from C++) drives sync correction, a perceptual volume curve shapes output, and CPU wake-ups were cut 63% through smart sleep and dead-loop removal. The AudioPlayer was hardened with lock-protected state via OSAllocatedUnfairLock, and the decoder got safety fixes for Opus overflow and interleaved access. Hardware vs. software volume control is now a first-class concept with VolumeMode, and an AudioProcessCallback allows consumers to tap the raw audio stream for visualization or effects.

On the protocol and networking side, the client supports mDNS advertisement for server-initiated connections, alongside direct client-initiated connections, supports seamless mid-stream format switching via stream/request-format, and implements controller role commands (play/pause/next/prev) and artwork retrieval. The transport layer was overhauled to fix data races, encoding waste, and disconnect semantics. fputs logging was replaced with structured os.Logger across every subsystem.

The public API went through a full ergonomics pass — typed errors, Sendable conformances, continuous server discovery, and a clean separation between SendspinClient and the roles it can assume. Internally, nearly every subsystem was hardened: magic numbers replaced with named constants, preconditions added, dead code removed (guided by a Periphery scan), and stale comments cleaned up. SwiftFormat and SwiftLint were integrated and enforced. The branch also adds a DocC documentation catalog, a MockTransport for testing, and integration tests for SendspinClient.

teancom and others added 30 commits April 11, 2026 10:39
Major fixes to get the client producing actual audio output:
- Remove conflicting .convertFromSnakeCase JSON decode strategy
- Gate audio chunk processing on clock sync completion
- Replace chunk-based AudioQueue buffer with continuous byte stream
- Left-shift 24-bit PCM samples to fill 32-bit AudioQueue range
- Add server/state, stream/clear, server/command message handlers
- Send client/goodbye with spec-defined reason on disconnect

Port the C++ reference time-filter (github.com/Sendspin-Protocol/time-filter)
as SendspinTimeFilter: full 2D Kalman with covariance matrix, drift process
noise, adaptive forgetting, drift significance SNR gate, and RTT floor from
the Rust implementation. Replaces the prior fixed-gain exponential smoother.

Also includes Sample, SamplePCMDecoder, SampleBufferPool, and SyncCorrection
primitives (tested but not yet wired into the playback path), cleanup of all
commented-out debug logging, and removal of the deprecated ClientRole enum.

127 tests passing across 19 suites.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
AudioPlayer now has frame-aware cursor tracking, drop/insert cadence
support, and CorrectionPlanner integration. The fillBuffer callback
operates frame-by-frame instead of bulk memcpy, with proper cursor
advancement using integer arithmetic to prevent drift.

The sync error computation and correction scheduling run at 100ms
intervals in a dedicated loop, with AudioQueue pipeline latency
compensation. However, the actual correction application is disabled
because the 100ms update interval causes a positive feedback loop:
corrections change the cursor rate, the next measurement sees that
as more error, and the system diverges.

Enabling this requires moving the error computation into or very near
the audio callback (like Rust's synced_player.rs does in the cpal
callback at ~5ms resolution). The infrastructure is in place and tested
for when that architectural change happens.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Sendspin spec requires volume 0-100 to represent perceived loudness,
not linear amplitude. Adds a 1.5-power curve (matching the Rust reference)
that maps linear volume to perceptual gain before passing to AudioQueue.

Volume 50 now produces ~35% amplitude (sounds roughly half as loud),
instead of 50% amplitude (sounds too loud for a "midpoint").

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three major changes that take sync from "40ms drift, no correction" to
"±1-3ms steady state with sub-millisecond peaks":

1. Protocol compliance: client/state now sends the required top-level
   `state` field and `static_delay_ms` (always required for players).
   Added set_static_delay server command handling and timestamp
   adjustment per spec.

2. PCMRingBuffer replaces Data-based audio buffer: O(1) read/write
   with no allocations after init, eliminating the O(n) memmove from
   Data.removeFirst() that was running 48,000x/sec on the audio thread.

3. Sync correction computed inside the AudioQueue callback: a
   TimeFilterSnapshot captures clock state without actor isolation,
   letting the audio thread compute error and run the CorrectionPlanner
   with zero async hops. Fixed a sign convention bug where the original
   code was amplifying drift instead of correcting it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- AudioScheduler: replace fixed 10ms poll (100/sec) with smart sleep
  that targets the next chunk's playTime, falling back to 50ms when
  the queue is empty (~42/sec during playback)
- CLIPlayer: delete monitorStats — a 10Hz no-op busy loop that was
  never implemented
- Telemetry loop: slow from 100ms to 500ms poll, telemetry from 1s
  to 2s intervals. Reanchors are rare; sync runs in the audio callback

3-minute smoke test confirms no regression: mean sync error -101µs,
stddev 3.2ms, 0% drops, 0 decode errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove Sample, SamplePCMDecoder, SampleBufferPool: tested but never
  wired into the audio pipeline. The Data→ring buffer path works great;
  typed sample processing can be built when actually needed.
- Remove stream/metadata and session/update message types: not in the
  Sendspin spec, not used by any other implementation. Metadata arrives
  via server/state per spec.
- Remove CLIPlayer monitorStats: was a 10Hz no-op busy loop.
- Rewrite integration tests to use the actual PCMDecoder path.

Net: -866 lines, 0 functionality lost.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add ServerControllerState to track supported_commands, group volume,
  and group mute from server/state messages
- Add volume/mute fields to ControllerCommand per spec
- Add public API on SendspinClient: play(), pause(), next(), previous(),
  stopPlayback(), setGroupVolume(), setGroupMute(), sendCommand()
- Emit controllerStateUpdated events when server pushes controller state
- CLIPlayer now advertises controller role and supports:
  [p]lay [pause] [n]ext [b]ack [s]top [gv N] group vol [gm/gu] group mute

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Flesh out the artwork@v1 role with full spec compliance:
- ArtworkModels.swift: ArtworkSource, ImageFormat, ArtworkChannel,
  StreamArtworkChannelConfig, and ArtworkConfiguration types
- ArtworkSupport now carries channels array for client/hello
- StreamStartArtwork decodes per-channel config from stream/start
- StreamRequestFormatMessage enables dynamic artwork channel changes
- SendspinClient tracks artwork stream state, rejects binary when
  inactive, and exposes requestArtworkFormat() public API
- StreamEndMessage now has payload with optional roles array per spec
- CLIPlayer advertises artwork role with album art channel

Also removes MetadataSupport (metadata role has no support object in
the spec; confirmed absent in both Rust and Python implementations).

Wire format verified interoperable against sendspin-cli server.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…tions

Add the spec's recommended connection method: client advertises
_sendspin._tcp.local. via Bonjour and servers connect to the client.

Architecture:
- SendspinTransport protocol abstracts outbound (Starscream) and
  inbound (Network.framework) WebSocket connections
- NWWebSocketTransport wraps NWConnection with WebSocket framing
  for server-initiated connections
- ClientAdvertiser uses NWListener with NWProtocolWebSocket for
  combined mDNS advertisement + WebSocket server in one object
- SendspinClient.acceptConnection() handles the server-initiated
  handshake path, sharing setup logic with connect(to:)

Multi-server support per spec:
- Tracks connectionReason (discovery vs playback) from server/hello
- Persists lastPlayedServerId via UserDefaults across reboots
- shouldSwitchToNewServer() implements the spec's decision logic
- Sends client/goodbye with another_server when switching

CLIPlayer gains --listen mode for server-initiated connections.
Verified end-to-end against sendspin-cli with --client flag:
full handshake, stream/start, 192kHz/24-bit audio streaming.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Four bugs found through live testing against Music Assistant server:

1. Competing connection consumed stream/start: when the server
   reconnected with connection_reason: playback, waitForServerHello
   ate the stream/start message from the text stream. Simplified
   handleCompetingConnection to disconnect-and-switch, letting the
   normal message loop handle all messages.

2. FLAC codec header not passed to decoder: the server sends a
   base64-encoded STREAMINFO block in stream/start.codec_header,
   but AudioDecoderFactory ignored the header parameter for FLAC.
   Without STREAMINFO, libFLAC can't parse any frames.

3. FLAC/Opus bit-depth mismatch: both decoders output Int32 (4 bytes
   per sample) regardless of source bit depth, but AudioQueue was
   configured for the source bit depth (e.g., 16-bit = 2 bytes).
   The audio callback read half-sized samples and produced silence.

4. Scheduler queue overflow: the 100-chunk queue limit caused Music
   Assistant's aggressive pre-buffering (~25s ahead) to evict
   about-to-play chunks. Removed the queue size limit entirely —
   the server already respects our buffer_capacity from client/hello.

Also reorders CLIPlayer format preferences: FLAC 24-bit first for
maximum fidelity (FLAC compression eliminates bandwidth penalty on
16-bit sources), standard rates before hi-res.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… switching

Add requestPlayerFormat() API for dynamic audio format negotiation per spec.
The client can now request codec, sample rate, and bit depth changes mid-stream
with near-imperceptible transition gaps and no pitch or noise artifacts.

Key design: on format change, the old AudioQueue keeps playing from its ring
buffer while new-format chunks are decoded and scheduled. Each chunk is tagged
with a stream generation counter. When the scheduler output loop encounters
the first new-generation chunk, it pre-buffers 2 chunks, rebuilds the
AudioQueue at the new sample rate, and feeds the buffered data. A 1-second
sync correction grace period after rebuild prevents audible pitch shifts from
transient sync error.

Also fixes FLAC decoder to use frame-header bit depth instead of init parameter,
preventing digital noise when transitioning between bit depths (e.g. 16↔24 bit).

Also adds:
- currentStreamFormat property for inspecting active format
- streamFormatChanged client event for mid-stream format changes
- Interactive 'f' command in CLIPlayer (e.g. 'f flac 48000 24')
- Graceful SIGINT handling (sends client/goodbye on Ctrl-C)
- stdin command support in CLIPlayer listen mode

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Make internal types internal (~60 types), hiding wire-format messages,
audio pipeline, sync correction, and clock sync from library consumers.
Add metadata/group delta accumulation per spec, typed enums for playback
state and controller commands, playback progress with real-time position
helper, normalized 0-100 volume API, disconnect events, and static delay
lifecycle management. Fix MetadataProgress types to match spec (Int not
Double). Eliminate all compiler warnings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…, cleanup

- Add Nullable<T> type to distinguish absent vs explicit null in JSON
  delta merges, fixing spec compliance for metadata field clearing
- Remove legacy playPCM (no-timestamp overload) — all callers use
  timestamped version via AudioScheduler
- Derive PCMRingBuffer capacity from PlayerConfiguration instead of
  hardcoding 512KB
- Delete debug test executables (AudioTest, FLACTest, OpusTest,
  SimpleTest) — unit test suite covers this; keep CLIPlayer
- Fix CLIPlayer stdin lifecycle: command loop no longer owns process
  lifetime, preventing premature exit when stdin is EOF
- Add CLIController example: controller-only client with no audio
  pipeline, demonstrating controller+metadata roles
- Add missing controller convenience methods: repeatOff/One/All,
  shuffle, unshuffle, switchGroup

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add discoverServers() overload returning ServerDiscovery with a live
AsyncStream that emits updated server lists as they appear/disappear
on the network. The existing one-shot discoverServers(timeout:) is
reimplemented on top of it. Fix stopDiscovery() to finish the stream
so for-await consumers exit cleanly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add VolumeMode (.software, .hardware, .none) to PlayerConfiguration so
apps can control how volume/mute commands are handled. Software mode
(default) uses AudioQueue gain as before. Hardware mode queries CoreAudio
device capabilities on macOS and controls the output device directly.
None mode disables volume/mute advertisement for fixed-volume devices.

Introduces VolumeControl protocol with SoftwareVolumeControl and
HardwareVolumeControl implementations. The client/hello supported_commands
are now derived from resolved capabilities instead of hardcoded.

Fully backwards compatible — default VolumeMode is .software.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…oPlayer integration

13 new tests covering VolumeCapabilities, VolumeControlFactory mode
resolution, SoftwareVolumeControl nil-queue safety, PlayerConfiguration
defaults, AudioPlayer delegation to injected VolumeControl, mute-skips-
volume behavior, and macOS HardwareVolumeControl device queries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
CLIPlayer now accepts --volume-mode <software|hardware|none> to select
the volume control strategy. Logs the active mode on startup for
verification. Defaults to software (unchanged behavior).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Equivalent to sendspin-rs ProcessCallback — a @sendable closure invoked on
every AudioQueue callback with the final buffer (including silence) before
playback. Enables VU meters, waveform visualizers, and real-time effects.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ftlint violations

Dead code removed:
- Unused import AVFoundation, dead error cases (AudioPlayerError, AudioDecoderError,
  SendspinClientError), dead ClockSynchronizer methods/types (SyncQuality, ClockStats,
  getStats, checkQuality), dead PCMUtilities (pack24Bit, convertTo16Bit, etc.),
  dead PCMRingBuffer peek methods, dead shouldSwitchToNewServer, dead droppedOther
  stats field, empty if block in AudioScheduler

Stale comments fixed:
- Removed ~12 "Logging disabled for TUI mode" comments from WebSocketTransport
- Fixed playPCM doc ("Play" → "Enqueue"), renamed schedulerStatsTask → syncTelemetryTask
- Updated ABOUTME comments (AudioScheduler, ClientAdvertiser, SendspinTransport)

SwiftLint (104 violations → 1 warning, 0 errors):
- Tuned .swiftlint.yml: added .build exclusion, identifier exclusions for loop/audio
  vars, documented length thresholds, downgraded force_cast to warning
- Fixed unused closure params, trailing whitespace, switch_case_alignment (added
  Nullable.isAbsent), line length violations, trailing commas, force casts in tests
- Extracted public types from SendspinClient → ClientTypes.swift (~110 lines)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Configure SwiftFormat (0.60.1) with Swift 6.3, complementing SwiftLint:
- SwiftFormat handles formatting (whitespace, braces, imports, wrapping)
- SwiftLint focuses on correctness (complexity, naming, force casts)

Key formatting applied:
- redundantSelf: removed unnecessary self. references
- redundantSendable: removed Sendable where compiler infers it
- sortImports: alphabetized import statements
- docComments: converted // to /// where describing declarations
- numberFormatting: added underscores to large literals (48_000, 1_000_000)
- blankLinesAtStartOfScope: removed blank lines after opening braces
- wrapPropertyBodies: expanded one-liner computed properties
- consecutiveSpaces: normalized spacing
- swiftTestingTestCaseNames/redundantSwiftTestingSuite: Swift Testing conventions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…LAC improvements

- Fix Opus float→Int32 overflow: Float(Int32.max) rounds up to 2147483648.0,
  so a sample of exactly 1.0 would trap. Use Int64 intermediate + clamping.
- Fix Opus stereo bug: swift-opus writes interleaved data into
  floatChannelData[0]; accessing floatChannelData[channel] for channel > 0
  was undefined behavior. Read the single interleaved pointer sequentially.
- PCMDecoder: class → struct (immutable after init, value semantics correct)
- FLACDecoder: replace O(n) removeFirst with index-based sliding window and
  high-water-mark compaction; add reserveCapacity in writeCallback
- FLACDecoder: change silent iteration guard break to thrown error
- FLACDecoder: guard let for baseAddress instead of force unwrap
- Add threading contract docs on OpusDecoder and FLACDecoder
- Add LocalizedError conformance to AudioDecoderError
- Fix FLAC tests: replace always-true nil checks with meaningful assertions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace all Date()-based timing with MonotonicClock using
CLOCK_MONOTONIC_RAW, immune to NTP slew adjustments that were causing
30+ minute Kalman filter convergence issues (reported in protocol chat).
Purely internal change — wire protocol compatibility unaffected.

MonotonicClock:
- New centralized time source using raw hardware oscillator
- Epoch anchor captured once at startup for TimeFilterSnapshot compat
- Replaces Date() in ClockSynchronizer, SendspinClient, AudioPlayer,
  and AudioScheduler

AudioPlayer hardening:
- defer { pcmBufferLock.unlock() } prevents deadlock from future changes
- Process callback/format read into locals under lock (closes race window)
- precondition on frame size vs lastFrameStorage (prevents silent corruption)
- AudioQueueStart failure now cleans up queue (prevents resource leak)
- Added underrunCount to TelemetrySnapshot for diagnostics
- Unmanaged.passUnretained invariant documented
- Removed redundant async from playPCM
- AudioPlayerError: LocalizedError, split into allocation/start failures

AudioFormatSpec:
- Added effectiveOutputBitDepth computed property (centralizes decoder
  output knowledge that was inline in AudioPlayer)

AudioScheduler:
- ScheduledChunk.playTime: Date → playTimeMicroseconds: Int64
- All scheduling uses MonotonicClock.absoluteMicroseconds()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…<LockedState>

Replace ~20 nonisolated(unsafe) properties with a single LockedState
struct wrapped in OSAllocatedUnfairLock<LockedState>. Lock discipline
is now structurally enforced — all shared state access goes through
withLock, making accidental unlocked reads/writes a compile error.

Key changes:
- LockedState struct holds all audio-thread-shared mutable state
- advanceCursor() becomes a mutating method on LockedState, enforcing
  the lock requirement structurally rather than via doc comment
- processCallback promoted to nonisolated let (set once, @sendable)
- fillBuffer uses single withLockUnchecked call returning FillResult,
  with process callback invoked after lock release (no priority inversion)
- deinit disposes AudioQueue via nonisolated audioQueueForDeinit mirror
- lastFrameStorage size derived from named constant maxFrameBytes
- precondition(sampleRate > 0) in advanceCursor prevents division by zero
- Delete custom UnfairLock.swift — OSAllocatedUnfairLock provides
  priority donation, heap allocation, and Sendable out of the box

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…Ms, drop generic

- Merge SchedulerStats and DetailedSchedulerStats into a single mutable
  struct with Sendable + Equatable conformance
- Fix bufferFillMs to measure to queue.last (total buffered duration)
  instead of queue.first (time-to-next-chunk)
- Replace generic AudioScheduler<ClockSync> with existential
  (any ClockSyncProtocol) to eliminate generic propagation
- Drop currentStats/getStats() dual API in favor of single computed
  stats property
- Rename get-prefixed accessors to properties (queuedChunks, stats)
- Replace fire-and-forget test Tasks with structured timeout racing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…spec

BufferManager tracked compressed chunk occupancy for client-side
backpressure, but was never wired up: stored in AudioPlayer but
never read, created/cleared in SendspinClient with no effect.

The spec's buffer_capacity in client/hello is a hint to the server,
which paces audio accordingly. The PCMRingBuffer already handles
overflow at the write side. No client-side tracking needed.

Removes BufferManager.swift, its unit/integration tests (~340 lines),
and the dead dependency from AudioPlayer and SendspinClient.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix docstring: was "virtual-capacity trick with modulo 2×capacity",
  actually uses free-running counters with bitmask
- Add precondition for positive capacity (zero/negative silently
  produced a 1-byte buffer)
- Clamp capacity before power-of-2 loop to prevent shift overflow
- Nil-out storage in deallocate() to prevent double-free UB
- Add @unchecked Sendable with doc explaining why ~Copyable can't
  be used (OSAllocatedUnfairLock requires Copyable state)
- Document write(_ Data) as not RT-safe (ARC traffic)
- Document reset() not zeroing memory (intentional, RT contract)
- Add edge-case test for capacity of 1
- Fix stale "peek" reference in test ABOUTME

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PCMUtilities had a single function (unpack24Bit) with a single caller
(PCMDecoder.decode24Bit). Inlined the unpacking directly and switched
to withUnsafeBytes to work on Data's underlying storage instead of
copying to an intermediate [UInt8] array.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…no magic numbers

- Add preconditions: deadband < engage < reanchorThreshold in init,
  sampleRate > 0 in plan()
- Extract tuning values as static let constants on CorrectionPlanner
  so tests reference them instead of duplicating magic numbers
- Remove dead correctionsPerSec <= 0.0 guard (unreachable given
  prior checks)
- CorrectionSchedule fields → let (never mutated after construction)
- Explicit Sendable on both types
- Remove unnecessary import Foundation (pure Swift)
- Explicit .rounded(.toNearestOrAwayFromZero) rounding rule
- Comment the Int64.min saturation guard
- AudioPlayer: use CorrectionPlanner() defaults instead of
  duplicating values at call site

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…aming

- Log OSStatus failures from AudioQueueSetParameter and
  AudioObjectSetPropertyData instead of silently discarding
- Extract setVolumeOnDevice() to deduplicate master/L+R channel
  fallback logic (was duplicated in setVolume and setMute)
- Rename VolumeCapabilities.none → .unsupported to avoid shadowing
  Optional.none
- Clamp currentVolume in hardware mute fallback path (was missing,
  asymmetric with setVolume which clamped)
- Add doc comment on VolumeControl protocol explaining mute/volume
  coordination contract between software and hardware modes
- Log warning when hardware volume falls back to software on
  non-macOS platforms
- Add Sendable to VolumeCapabilities
- Float(0.0) → 0.0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Public API improvements:
- artworkUrl → artworkURL (Swift API Design Guidelines)
- playbackSpeed → playbackSpeedX1000 with playbackSpeedMultiplier
  computed property (unit-confusion prevention)
- supportedCommands: [Array] → Set<ControllerCommandType> (O(1) contains)
- staticDelayChanged(Int) → staticDelayChanged(milliseconds: Int)
- currentPositionMs returns Int64 (avoids 32-bit truncation)
- Hashable on all public value types (free synthesis)
- RepeatMode moved above TrackMetadata for logical grouping

Error typing:
- Remove dead ClientEvent.error(String) — was never yielded
- Create typed ClientError enum (unsupportedCodec, audioStartFailed)
- ConnectionState.error(String) → ConnectionState.error(ClientError)
- Delete manual Equatable on ConnectionState (now synthesized)
- Add LocalizedError to SendspinClientError

Other:
- Clamp controller volume to 0–100 at construction site
- Explicit Hashable on ConnectionReason, GoodbyeReason
- Doc comments: PlaybackState (no paused per spec), ControllerState
  (volume range), struct construction intent
- Fix CLIPlayer/CLIController examples for renamed properties

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
teancom and others added 9 commits April 14, 2026 08:44
TimeFilterSnapshot no longer has .invalid or isValid — a nil snapshot
means "no sync data" and a non-nil snapshot is always valid. The
compiler now enforces the validity check at every call site, eliminating
the silent-zero footgun in the audio callback path.

- ClockSynchronizer.snapshot() returns TimeFilterSnapshot?
- AudioPlayer.timeSnapshot becomes TimeFilterSnapshot? (nil = unsynchronized)
- updateTimeSnapshot takes non-optional; unwrap pushed to SendspinClient
- Equatable conformance; drop unused import Foundation
- Rename localToServerTime → localTimeToServer to match ClockSynchronizer

Tests (14): known-value conversions, negative offset, round-trips,
drift gating, and parity with SendspinTimeFilter (with and without
drift). Clean under -strict-concurrency=complete.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Ensures stale sync data doesn't persist after playback stops. Currently
harmless since the player is discarded on disconnect, but prevents
subtle bugs if stop() is ever called without discarding the player.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…emantics

WebSocketTransport:
- Fix isConnected: track real connection state via OSAllocatedUnfairLock
  instead of checking webSocket != nil (stayed true after drops)
- Eliminate @unchecked Sendable: wrap all mutable delegate state in a
  lock-protected DelegateState struct for genuine Sendable conformance
- Fix disconnect(): delegate to handleDisconnection() for consistent
  state management; use CancellationError (not connectionFailed) for
  caller-initiated disconnects
- Guard send/sendBinary on isConnected (with TOCTOU caveat documented)
- Preserve WebSocket close code and reason from .disconnected events
  via new TransportError.disconnected(reason:code:) case
- Preserve original error from Starscream .error events
- Remove dead .encodingFailed case and dead URL path init logic
- Make isConnected nonisolated (lock is the real sync mechanism)

NWWebSocketTransport:
- Eliminate Data→String→Data round-trip in send(): encode directly to
  Data and send via NWConnection without String intermediate
- Fix stale comments about close frame handling

Shared:
- Extract SendspinEncoding to dedicated file with makeEncoder() factory
- Store JSONEncoder per actor with confinement warnings
- TransportError: LocalizedError + CustomStringConvertible

Clean under -strict-concurrency=complete.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…tion

CLIPlayer and CLIController failed to build after discoverServers became
throwing and rawAudioChunk was added to ClientEvent. Mark telemetry and
AudioQueue log interpolations as public so `log stream` can display them
(no PII — just counters, timing, and format specs).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Make command methods (play, pause, next, etc.) throw instead of
  silently swallowing errors; wrap transport errors in typed
  SendspinClientError.sendFailed for public error surface
- Expose currentVolume/currentMuted as public observable properties
- Add connect(to:) alreadyConnected error instead of silent no-op
- Surface activeRoles in ServerInfo with hasRole() convenience
- Add setRepeatMode(_:), setShuffle(_:), enterExternalSource(),
  exitExternalSource() with transactional rollback on send failure
- Emit streamCleared and lastPlayedServerChanged events; remove
  UserDefaults coupling for lastPlayedServerId
- Narrow visibility: PlayerCommand, WebSocketTransport,
  NWWebSocketTransport, TransportError all demoted to internal
- Extract SendspinClient+Commands.swift to stay under file length limit
- Standardize doc comment style, add comments for deliberate try? usage

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move text/binary dispatch, protocol message handlers, and audio chunk
processing (~420 lines) into SendspinClient+MessageHandling.swift.

SendspinClient.swift drops from 1115 to 696 lines, well under the
900-line SwiftLint warning threshold. Properties accessed by the
extension changed from private to internal (same-module visibility).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add MockTransport actor conforming to SendspinTransport for testing
without a real WebSocket — supports injectable streams, sent message
capture, and simulated send failures.

Add 8 integration tests: operational state rollback, streamCleared
event, lastPlayedServerChanged event, setRepeatMode/setShuffle wire
format, sendFailed error wrapping, activeRoles from server/hello,
and alreadyConnected guard.

Also clean up existing test files:
- Replace 17 magic number literals with BinaryMessageType constants
- Replace non-deterministic Int64.random with fixed jitter sequence
- Replace force-unwraps with try #require for test safety
- Use Int16(littleEndian:) for explicit endianness
- Use BinaryMessage.headerSize in size assertions
- Remove dead-weight sequential round-trip test and helpers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…tream/start

Binary and text messages are consumed by parallel tasks in the message
loop. When the server sends audio chunks immediately after stream/start,
the binary task can process all chunks before the text task reaches
handleStreamStart — leaving shouldEmitRawAudio false and silently
dropping every rawAudioChunk event.

Move initialization to setupConnection so the flag is set before the
message loop starts. Add regression test that injects binary audio
chunks before stream/start and asserts all rawAudioChunk events arrive.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add a DocC catalog with landing page and four guide articles (Getting
Started, Discovery, Events, Audio Pipeline) plus the swift-docc-plugin
for CLI doc generation.

Fix the CI workflow: correct test binary names (was referencing
ResonateKit/MusicAssistantKit), remove unused SKIP_INTEGRATION_TESTS
flag, split lint and test into parallel jobs, add concurrency groups,
and remove the broken integration-test job that referenced non-existent
test filters and stale secrets.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@balloob balloob left a comment

Choose a reason for hiding this comment

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

SendspinKit PR #6 — Comprehensive Architecture Review

I read every source file in this PR (all 87 changed files, 10K additions). This is genuinely impressive work — taking a rough prototype to a spec-compliant, production-hardened client library. The architecture is fundamentally sound, the concurrency model is well-thought-out, and the audio pipeline is correctly layered. The inline comments cover the most important issues; this summary provides the full picture.


What's Done Well

Audio pipeline architecture — The layering (AudioScheduler → AudioPlayer → PCMRingBuffer → AudioQueue callback) is clean. The separation between the async scheduling actor and the real-time C callback is the right design. The LockedState pattern with OSAllocatedUnfairLock for audio thread synchronization is textbook correct.

Kalman time filter — Faithful port with full 2D covariance propagation, adaptive forgetting, drift SNR gating, and RTT floor. The math checks out. The TimeFilterSnapshot pattern for crossing actor→audio-thread boundaries without actor hops is elegant.

Nullable<T> for JSON delta merges — Clean solution to the absent-vs-null-vs-value problem. The Encodable guard that throws for .absent prevents misuse. The ServerMetadataState usage pattern is correct.

Transport protocol abstraction — The inbound/outbound split (WebSocketTransport vs NWWebSocketTransport) with a shared SendspinTransport protocol is well-motivated. The common interface covers exactly the right surface area.

VersionedRole designExpressibleByStringLiteral for Swift-side ergonomics, strict Decodable for wire format. Static constants for spec-defined roles. Clean.

Test quality (where it exists)PCMRingBufferTests is exemplary. AudioPipelineIntegrationTests are genuine end-to-end pipeline tests. SendspinTimeFilterTests demonstrates rigorous numerical verification. ClientIntegrationTests test 7 (raw audio chunks before stream/start) is an excellent regression test for a specific race condition.

DocC catalog — Good investment for a library. The articles cover discovery, events, and the audio pipeline.


Critical Issues (2)

# Issue Location
C1 precondition in public initializers crashes host appPlayerConfiguration, ArtworkConfiguration, AudioFormatSpec, ArtworkChannel, and SendspinClient.init all use precondition/preconditionFailure for input validation. A library must never trap for invalid input. Use throwing initializers. Multiple files (see inline)
C2 precondition on audio thread in advanceCursor() — Trapping from the AudioQueue callback kills the host process. Should be guard+return or debug-only assert. AudioPlayer.swift:96

Major Issues (12)

# Issue Location
M1 Try-all-types JSON decoding chainhandleTextMessage tries decoding every message type sequentially. Should extract "type" first (already done!) then switch-decode in one pass. SendspinClient+MessageHandling.swift:24-43
M2 HardwareVolumeControl missing perceptual gain curve — Software volume applies pow(v, 1.5), hardware volume passes raw linear. Volume 50% sounds different between modes. VolumeController.swift:79
M3 public internal(set) on ~6 state properties — Overly wide mutation surface. Should be public private(set) with internal setter methods for extensions. No state machine validation on transitions. SendspinClient.swift:27-62
M4 AudioScheduler.queue uses O(n) Array operationsinsert(at:) and removeFirst() shift the entire array. With 2500+ chunks buffered, this is significant. Use Deque or index-tracking. AudioScheduler.swift:93,169,179
M5 WebSocket connect() continuation leak — If Starscream never calls back or the task is cancelled, the stored continuation is never resumed. Needs withTaskCancellationHandler. WebSocketTransport.swift:140-143
M6 sendCommand exposes invalid parameter combinationssendCommand(.play, volume: 50) compiles but is nonsensical. Make it internal; the typed convenience methods are the correct API. SendspinClient+Commands.swift:141
M7 PlaybackProgress.currentPositionMs(at:) unusable — Requires server clock domain timestamp but no public method to obtain one. Dead API for consumers. ClientTypes.swift:147
M8 No recovery path from ConnectionState.error — Client is stuck in degraded state. No docs or API for recovery. ConnectionState.swift:37-42
M9 PlayerStateObject validation bypassed by synthesized Decodable — Preconditions in manual init are never called during JSON decoding. Malformed server data accepted silently. SendspinMessage.swift:255-258
M10 Per-call heap allocation in Opus/PCM decodersOpusDecoder.decode() and PCMDecoder.decode24Bit() allocate on every call. FLACDecoder correctly reuses buffers — others should too. AudioDecoder.swift:60,135
M11 ServerDiscovery.deinit accesses actor-isolated statependingResolves and resolveTimeoutTasks accessed from non-isolated deinit. Formal data race in Swift 6 strict concurrency. ServerDiscovery.swift:304-313
M12 ServerDiscovery has zero test coverage — Resolve logic, IPv6 formatting, TXT record parsing, error retry, TerminatedError, stream lifecycle all untested. Missing ServerDiscoveryTests.swift

Minor Issues (15)

# Issue Location
m1 Three fragmented error types (TerminatedError, SendspinClientError, StreamingError) with no shared namespace SendspinKit.swift, ClientTypes.swift, ConnectionState.swift
m2 ClientEvent.streamCleared discards role information — consumers can't tell which stream cleared ClientTypes.swift:60
m3 AudioQueue callback lock scope is very wide (~85ms at 48kHz) — blocks actor's playPCM writes AudioPlayer.swift:456-563
m4 swapDecoder doesn't update currentFormat — stale value during format transition window AudioPlayer.swift:338-346
m5 discoverServers(timeout:) task-group logic is subtly fragile with race-dependent result ordering SendspinClient.swift:145-171
m6 Volume API uses Int(0-100) externally but Float(0-1) internally — inconsistent mental model, undocumented SendspinClient.swift:633
m7 setVolume/setMute throw for .notConnected but silently swallow server notification failures — asymmetric error behavior SendspinClient.swift:638,656
m8 discoveries.values yields unstable ordering in servers stream — could cause unnecessary UI reloads ServerDiscovery.swift:291
m9 Two SendspinClientTests are no-ops that assert nothing beyond initialization SendspinClientTests.swift:28-72
m10 AudioProcessCallbackTests all rely on 500ms Task.sleep — flaky on CI without audio hardware AudioProcessCallbackTests.swift
m11 ClientAdvertiserTests has no tests for connection acceptance, isTerminated, or TerminatedError ClientAdvertiserTests.swift
m12 Convenience controller methods don't document what happens if the server doesn't support the command SendspinClient+Commands.swift:148-229
m13 currentVolume resets to hardcoded 100 on disconnect rather than the initial configured value SendspinClient.swift:297
m14 disconnect() goodbye delivery is best-effort (try?) but not documented as such SendspinClient.swift:268
m15 In-range but unrecognized binary type IDs (5-7, 17-23) silently dropped with no log BinaryMessage.swift:57

Architecture Assessment

Overall: Strong foundation, needs consumer-facing polish

The internal architecture is solid — the audio pipeline, clock sync, and transport layers are well-separated with clean abstractions. The concurrency model is correct: actors for async state, OSAllocatedUnfairLock for the audio thread, nonisolated let for cross-boundary data, TimeFilterSnapshot for lock-free reads.

Decoupling

The major coupling concern is SendspinClient itself — at ~700 lines across 3 files, it's the orchestrator and necessarily touches everything. The extension split (+Commands, +MessageHandling) is the right organizational approach. The internal (not private) properties for extension access is pragmatic but should be tightened to private(set) with setter methods.

The audio pipeline is well-decoupled: AudioScheduler knows nothing about codecs, AudioPlayer knows nothing about networking, PCMRingBuffer knows nothing about audio. Each can be tested independently. The ClockSyncProtocol enables mock injection for scheduler tests.

Consumer Ergonomics

This is where the most work remains. The API reads well for the happy path (connect, play, pause), but:

  • Error handling is fragmented (3 error types, precondition traps, mixed throw/swallow patterns)
  • PlaybackProgress.currentPositionMs(at:) is dead API without a server time accessor
  • ConnectionState.error is a trap with no documented exit
  • Configuration validation crashes instead of throwing

Top 3 Recommendations

  1. Replace all precondition in public APIs with throwing inits. This is the single most impactful change for consumer safety.
  2. Switch handleTextMessage to type-first dispatch. Extract the type string (already done), then switch to decode the correct type in one pass. Simpler, faster, no cross-type ambiguity.
  3. Expose server time for PlaybackProgress. Either add client.currentServerTimeMicroseconds() or compute it internally — without this, the progress API is unusable for building UI.

Generated by Claude Code

Comment thread Sources/SendspinKit/Client/PlayerConfiguration.swift Outdated
Comment thread Sources/SendspinKit/Audio/AudioPlayer.swift
Comment thread Sources/SendspinKit/Client/SendspinClient+MessageHandling.swift Outdated
Comment thread Sources/SendspinKit/Audio/VolumeController.swift Outdated
Comment thread Sources/SendspinKit/Client/SendspinClient.swift Outdated
Comment thread Sources/SendspinKit/Models/SendspinMessage.swift Outdated
Comment thread Sources/SendspinKit/Client/ConnectionState.swift
Comment thread Sources/SendspinKit/Audio/AudioDecoder.swift Outdated
Comment thread Sources/SendspinKit/Discovery/ServerDiscovery.swift
Comment thread Sources/SendspinKit/SendspinKit.swift
teancom and others added 12 commits April 15, 2026 10:39
…y safety

A library must never crash the host process for invalid input. All public
initializers that used precondition/preconditionFailure for validation now
throw a unified ConfigurationError type instead. This covers
PlayerConfiguration, ArtworkConfiguration, AudioFormatSpec, ArtworkChannel,
ArtworkFormatRequest, PlayerStateObject, and SendspinClient.init.

Also fixes PlayerStateObject's synthesized Decodable init bypassing
validation — it now has a custom init(from:) that validates volume and
static delay ranges on decode. The audio thread precondition in
advanceCursor() is replaced with a guard+return to avoid killing the host
from a real-time callback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the sequential try-all-types decoding chain with a switch on the
extracted type string. Each message is now decoded exactly once in a single
pass, eliminating the O(n) cost of trying earlier types and removing
cross-type ambiguity from messages with all-optional payloads.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Change public state properties from internal(set) to private(set) with
internal setter methods, so extension files mutate state through named
methods rather than direct assignment. This makes the mutation surface
controlled and auditable while keeping cross-file access working.

Document the ConnectionState lifecycle including the recovery path from
.error — call disconnect() then connect() to recover. The client does not
auto-recover because the host app may need to show an error UI or switch
to a different server.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ions

AudioScheduler now uses an index-based read cursor instead of
Array.removeFirst() for consuming chunks. The consumed prefix is
compacted when it exceeds half the array, amortizing the copy cost.
Binary search for insertSorted runs over the unconsumed portion only.
With 2500+ chunks buffered (typical for servers that pre-buffer 25s),
this reduces per-chunk cost from O(n) to amortized O(1).

OpusDecoder and PCMDecoder now reuse persistent buffers across decode()
calls instead of allocating on every call. PCMDecoder becomes a class
(matching OpusDecoder and FLACDecoder) to hold the reusable buffer.
FLACDecoder already had this optimization.

Also fixes a missing `try` in CLIController for the now-throwing
SendspinClient initializer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
HardwareVolumeControl was passing raw linear volume to CoreAudio while
SoftwareVolumeControl applied a 1.5-power perceptual curve. This meant
volume 50% sounded noticeably different between modes. Now both paths
apply the same perceptual gain curve for consistent perceived loudness.

Also documents the Int(0-100) to Float(0.0-1.0) volume conversion in
the public setVolume API, explaining the perceptual curve and how it
maps to both software and hardware volume modes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… connect

Wrap the WebSocket connection continuation with withTaskCancellationHandler
so that if the calling task is cancelled while awaiting the Starscream
callback, the continuation is properly resumed with CancellationError.
Previously, a cancelled task or a Starscream callback that never fires
would leak the suspended continuation indefinitely.

The cancellation handler consumes the continuation under the same lock
as the normal connect/error paths, preventing double-resume.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Make sendCommand internal — the typed convenience methods (play(), pause(),
etc.) are the correct public API and prevent invalid parameter combinations
like sendCommand(.play, volume: 50).

Add currentServerTimeMicroseconds() so consumers can use
PlaybackProgress.currentPositionMs(at:) to compute real-time interpolated
playback position. Without this, the progress API was unusable.

Add role information to streamCleared events so consumers can distinguish
which stream was cleared (player vs artwork vs all).

Document controller command behavior (server support check), best-effort
goodbye delivery semantics, and the deliberate asymmetry in volume/mute
error handling (throw for missing player, swallow server notification
failure).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Mark pendingResolves and resolveTimeoutTasks as nonisolated(unsafe) in
ServerDiscovery to fix the formal data race when deinit accesses
actor-isolated state in Swift 6 strict concurrency. Both NWConnection
.cancel() and Task.cancel() are thread-safe, matching the existing
pattern used for updateContinuation.

Sort discovered servers by name before yielding to the async stream,
preventing unstable Dictionary.values ordering from causing unnecessary
UI reloads in consumers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add a SendspinError marker protocol that all library error types conform
to: TerminatedError, SendspinClientError, StreamingError, and
ConfigurationError. Consumers can now catch any library error with
`catch let error as SendspinError` or match specific types for targeted
handling, without needing to discover each error type independently.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…y types

swapDecoder now updates currentFormat after creating the new decoder, so
the format stays consistent during seamless mid-stream transitions.
Previously currentFormat was stale during the transition window.

Unrecognized binary message type IDs (in-range values that don't match
any known case) are now logged as warnings instead of being silently
dropped, aiding protocol debugging.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove two no-op tests in SendspinClientTests that only asserted the
initial disconnected state (already covered by other tests).

Replace hardcoded 500ms Task.sleep waits in AudioProcessCallbackTests
with a pollUntil helper that checks every 25ms with a 2-second timeout.
Tests now complete as soon as the callback fires (~50-100ms) instead of
always waiting 500ms, and tolerate slow CI environments without audio
hardware.

Add missing ClientAdvertiser tests: isTerminated lifecycle transitions,
TerminatedError on start-after-stop, and fresh instance state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add 9 tests covering ServerDiscovery's testable surface without requiring
real network: termination state transitions, idempotent stop, stream
lifecycle after termination, TerminatedError description and protocol
conformance.

Network-dependent behavior (resolve logic, IPv6 formatting, TXT record
parsing, error retry) would require mocking NWBrowser/NWConnection and
is deferred to integration tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@teancom
Copy link
Copy Markdown
Contributor Author

teancom commented Apr 15, 2026

Thank you for the feedback! I've (attempted to) address the areas of concern.

teancom and others added 2 commits April 15, 2026 12:31
…6.1 CI

Backtick-quoted function names with spaces (e.g. func `descriptive name`())
require Swift 6.2+. CI runs Swift 6.1.2 and rejects them as parse errors.

Convert all 25 test files to camelCase identifiers. Also:
- Set SwiftFormat --swiftversion to 6.1 (matching CI) so it stops converting
  camelCase test names back to backtick style
- Add a SwiftLint custom rule (no_backtick_space_func_names) that errors on
  the pattern so future PRs are caught pre-commit
- Re-enable validates_start_with_lowercase which was previously disabled to
  accommodate the backtick style

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The find command searched .build/debug with exclusion patterns that
filtered out the actual binary location. On Swift 6.1+ the test binary
lives at .build/<triple>/debug/SendspinKitPackageTests.xctest/Contents/MacOS/,
not directly under .build/debug/. Search .build for the canonical
xctest bundle path instead, and also locate default.profdata dynamically
rather than hardcoding its path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@balloob balloob merged commit 9253dd8 into Sendspin:main Apr 15, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants