🎉 live server seems to be working now
This commit is contained in:
226
third_party/sendspin-go/docs/2026-04-12-layered-architecture-design.md
vendored
Normal file
226
third_party/sendspin-go/docs/2026-04-12-layered-architecture-design.md
vendored
Normal file
@@ -0,0 +1,226 @@
|
||||
# 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):
|
||||
|
||||
1. **Raw connection** — WebSocket transport and message types
|
||||
2. **Data processing** — clock sync, decoding, scheduling
|
||||
3. **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.
|
||||
|
||||
```go
|
||||
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.
|
||||
|
||||
```go
|
||||
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:
|
||||
|
||||
1. Creates a `Receiver` from its config fields.
|
||||
2. Calls `receiver.Connect()`.
|
||||
3. Sets `sync.SetGlobalClockSync(receiver.ClockSync())` for backward compat.
|
||||
4. Starts a goroutine that reads from `receiver.Output()` and for each buffer:
|
||||
- Calls `config.ProcessCallback(buf.Samples)` if set.
|
||||
- Calls `output.Write(buf.Samples)`.
|
||||
|
||||
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
|
||||
|
||||
```go
|
||||
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
|
||||
|
||||
- `Receiver` creates and owns its own `sync.ClockSync` instance.
|
||||
- `Player` still calls `sync.SetGlobalClockSync()` after connect for backward compat.
|
||||
- `sync.SetGlobalClockSync()` and `sync.ServerMicrosNow()` are deprecated (log warning on first use).
|
||||
- Multiple `Receiver` instances can coexist in one process, each with independent clock sync.
|
||||
|
||||
## Consumer Use Cases
|
||||
|
||||
### Visualizer (raw PCM, no playback)
|
||||
|
||||
```go
|
||||
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)
|
||||
|
||||
```go
|
||||
player, _ := sendspin.NewPlayer(sendspin.PlayerConfig{
|
||||
ServerAddr: "192.168.1.50:8927",
|
||||
PlayerName: "Living Room",
|
||||
})
|
||||
player.Connect()
|
||||
defer player.Close()
|
||||
```
|
||||
|
||||
### Player with VU meter
|
||||
|
||||
```go
|
||||
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
|
||||
|
||||
```go
|
||||
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.
|
||||
Reference in New Issue
Block a user