Conversation
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>
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>
balloob
left a comment
There was a problem hiding this comment.
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 design — ExpressibleByStringLiteral 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 app — PlayerConfiguration, 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 chain — handleTextMessage 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 operations — insert(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 combinations — sendCommand(.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 decoders — OpusDecoder.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 state — pendingResolves 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,
preconditiontraps, mixed throw/swallow patterns) PlaybackProgress.currentPositionMs(at:)is dead API without a server time accessorConnectionState.erroris a trap with no documented exit- Configuration validation crashes instead of throwing
Top 3 Recommendations
- Replace all
preconditionin public APIs with throwing inits. This is the single most impactful change for consumer safety. - Switch
handleTextMessageto type-first dispatch. Extract the type string (already done), thenswitchto decode the correct type in one pass. Simpler, faster, no cross-type ambiguity. - Expose server time for
PlaybackProgress. Either addclient.currentServerTimeMicroseconds()or compute it internally — without this, the progress API is unusable for building UI.
Generated by Claude Code
…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>
|
Thank you for the feedback! I've (attempted to) address the areas of concern. |
…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>
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.