7.3 KiB
Layered Architecture Design: Receiver + Player Split
Date: 2026-04-12 Status: Approved Target version: v1.2.0
Motivation
The sendspin-go SDK tightly couples audio decoding, clock sync, scheduling, and playback into a single Player type. Consumers who want decoded audio bytes without playback (visualizers, DSP pipelines, custom output backends, embedded devices) cannot use the library without initializing an audio device.
The Sendspin project is standardizing a 3-layer architecture across SDKs (see sendspin-rs):
- Raw connection — WebSocket transport and message types
- Data processing — clock sync, decoding, scheduling
- Playback — audio output to hardware
This design splits pkg/sendspin.Player into a Receiver (layers 1+2) and a slimmed Player (layer 3 + convenience wrapper), with no breaking changes.
Design
Receiver
The Receiver is the core new type. It handles: connect, handshake, clock sync, decode, and schedule. It emits time-stamped, decoded audio buffers via a channel.
type ReceiverConfig struct {
ServerAddr string
PlayerName string
BufferMs int // default: 500
DeviceInfo DeviceInfo
DecoderFactory func(audio.Format) (decode.Decoder, error) // nil = default
OnMetadata func(Metadata)
OnStreamStart func(audio.Format)
OnStreamEnd func()
OnError func(error)
}
type Receiver struct {
client *protocol.Client
clockSync *sync.ClockSync // own instance, not global
scheduler *Scheduler
decoder decode.Decoder
// ... context, state
}
func NewReceiver(config ReceiverConfig) (*Receiver, error)
func (r *Receiver) Connect() error
func (r *Receiver) Output() <-chan audio.Buffer // closed when Receiver.Close() is called
func (r *Receiver) ClockSync() *sync.ClockSync
func (r *Receiver) Stats() ReceiverStats
func (r *Receiver) Close() error
The Receiver owns all goroutines currently in Player: handleStreamStart, handleAudioChunks, clockSyncLoop, handleStreamClear, handleStreamEnd, handleServerState, handleGroupUpdates, and watchConnection.
Each Receiver creates its own sync.ClockSync instance. It never touches the global.
Player (Refactored)
Player becomes a thin wrapper composing Receiver + output.Output + optional hooks. The existing public API is fully preserved.
type PlayerConfig struct {
// Existing fields (unchanged)
ServerAddr string
PlayerName string
Volume int
BufferMs int
DeviceInfo DeviceInfo
OnMetadata func(Metadata)
OnStateChange func(PlayerState)
OnError func(error)
// New optional fields
Output output.Output // nil = auto-select
DecoderFactory func(audio.Format) (decode.Decoder, error) // nil = default
ProcessCallback func([]int32) // tap before output
}
type Player struct {
receiver *Receiver
output output.Output
config PlayerConfig
// ... state, context
}
Player.Connect() internally:
- Creates a
Receiverfrom its config fields. - Calls
receiver.Connect(). - Sets
sync.SetGlobalClockSync(receiver.ClockSync())for backward compat. - Starts a goroutine that reads from
receiver.Output()and for each buffer:- Calls
config.ProcessCallback(buf.Samples)if set. - Calls
output.Write(buf.Samples).
- Calls
Output auto-selection (when PlayerConfig.Output is nil) creates a malgo backend to handle all bit depths:
- All formats ->
output.NewMalgo()(supports 16/24/32-bit natively)
Player retains: output lifecycle, volume/mute control, ProcessCallback, state change notifications.
ProcessCallback
type ProcessCallback func([]int32)
Called with decoded samples (24-bit range int32) before every output.Write. Runs on the audio consumption goroutine. Consumers must not block — same constraints as the Rust SDK's callback.
Use cases: VU meters, visualization overlays, audio monitoring alongside playback.
For consumers who want audio WITHOUT playback, use Receiver directly.
ClockSync Changes
Receivercreates and owns its ownsync.ClockSyncinstance.Playerstill callssync.SetGlobalClockSync()after connect for backward compat.sync.SetGlobalClockSync()andsync.ServerMicrosNow()are deprecated (log warning on first use).- Multiple
Receiverinstances can coexist in one process, each with independent clock sync.
Consumer Use Cases
Visualizer (raw PCM, no playback)
recv, _ := sendspin.NewReceiver(sendspin.ReceiverConfig{
ServerAddr: "192.168.1.50:8927",
PlayerName: "My Visualizer",
})
recv.Connect()
for buf := range recv.Output() {
visualizer.ProcessAudio(buf.Samples)
}
Standard player (unchanged)
player, _ := sendspin.NewPlayer(sendspin.PlayerConfig{
ServerAddr: "192.168.1.50:8927",
PlayerName: "Living Room",
})
player.Connect()
defer player.Close()
Player with VU meter
player, _ := sendspin.NewPlayer(sendspin.PlayerConfig{
ServerAddr: "192.168.1.50:8927",
PlayerName: "Living Room",
ProcessCallback: func(samples []int32) {
vuMeter.Update(samples)
},
})
player.Connect()
Custom output backend
player, _ := sendspin.NewPlayer(sendspin.PlayerConfig{
ServerAddr: "192.168.1.50:8927",
PlayerName: "Custom Device",
Output: myALSAOutput,
})
player.Connect()
File Structure
No new packages. Receiver lives in pkg/sendspin alongside Player.
pkg/sendspin/
receiver.go NEW — Receiver type, all connection/decode/sync goroutines
player.go SLIMMED — composes Receiver + Output, volume/mute, ProcessCallback
scheduler.go UNCHANGED
server.go UNCHANGED
source.go UNCHANGED
pkg/sync/
clock.go MINOR — deprecation warnings on global functions
All other packages UNCHANGED.
API Changes Summary
New
| Symbol | Purpose |
|---|---|
sendspin.ReceiverConfig |
Config for data-only client |
sendspin.NewReceiver() |
Create a Receiver |
Receiver.Connect() |
Connect and start pipeline |
Receiver.Output() |
Channel of decoded audio.Buffer |
Receiver.ClockSync() |
Access clock sync instance |
Receiver.Stats() |
Pipeline statistics |
Receiver.Close() |
Tear down |
PlayerConfig.Output |
Inject custom output backend |
PlayerConfig.DecoderFactory |
Inject custom decoder |
PlayerConfig.ProcessCallback |
Tap audio before output |
Deprecated
| Symbol | Replacement |
|---|---|
sync.SetGlobalClockSync() |
Use Receiver.ClockSync() |
sync.ServerMicrosNow() |
Use receiver.ClockSync().ServerToLocalTime() |
Breaking Changes
None.
Testing
- Receiver unit tests: connect with mock server, verify buffers arrive on Output channel with correct timestamps.
- Player integration tests: verify existing behavior is preserved when refactored to use Receiver internally.
- ProcessCallback test: verify callback fires with correct samples before output.Write.
- Multi-receiver test: two Receivers to different mock servers, verify independent clock sync.
- DecoderFactory test: inject a custom decoder, verify it's used instead of the default.
- Output injection test: inject a mock output, verify Write is called with decoded samples.