Files
-rpi-sendspin/third_party/sendspin-go/pkg/sendspin/receiver.go

873 lines
25 KiB
Go

// ABOUTME: Receiver handles connection, sync, decode, and scheduling
// ABOUTME: Emits decoded audio.Buffer via Output() channel for consumers
package sendspin
import (
"context"
"encoding/base64"
"fmt"
"log"
"math"
"sort"
"strings"
stdsync "sync"
"time"
"github.com/Sendspin/sendspin-go/pkg/audio"
"github.com/Sendspin/sendspin-go/pkg/audio/decode"
"github.com/Sendspin/sendspin-go/pkg/protocol"
"github.com/Sendspin/sendspin-go/pkg/sync"
"github.com/gorilla/websocket"
)
// Time-sync burst parameters. Mirrors sendspin-cpp's TimeBurst defaults and
// the upstream Sendspin/time-filter README "Recommended Usage" guidance: a
// short burst of NTP-style exchanges, each waiting for its reply, with the
// best (lowest RTT) sample fed to the filter once per burst.
const (
timeSyncBurstSize = 8
timeSyncBurstInterval = 10 * time.Second
timeSyncResponseTimeout = 500 * time.Millisecond
)
// metadataApplyTickInterval is the cadence at which metadataApplyLoop wakes
// to drain pending updates whose server timestamp has elapsed. 100 ms is
// imperceptible for metadata display lag; do not shorten without a real
// reason.
const metadataApplyTickInterval = 100 * time.Millisecond
type ReceiverConfig struct {
ServerAddr string
PlayerName string
BufferMs int
StaticDelayMs int // optional static latency compensation (ms) applied to every scheduled play time
PreferredCodec string // "pcm", "opus", or "flac" — reorders the advertised format list so the server picks this codec first
BufferCapacity int // buffer_capacity in bytes advertised to the server (default: 1048576 = 1MB)
// MaxSampleRate caps the highest SampleRate advertised to the server.
// 0 = no cap. Set this when the eventual audio output device cannot
// sustain higher rates (e.g. Pi3 onboard bcm2835 headphones can't
// actually drain 192k even though ALSA reports it accepts the format).
MaxSampleRate int
// MaxBitDepth caps the highest BitDepth advertised to the server.
// 0 = no cap. See MaxSampleRate for the motivating case.
MaxBitDepth int
// ClientID is the already-resolved client_id to advertise in client/hello.
// Required — callers should compute this once at startup (typically via
// ResolveClientID) and thread the same value through reconnects.
ClientID string
DeviceInfo DeviceInfo
DecoderFactory func(audio.Format) (decode.Decoder, error)
// OnMetadata is invoked after each server metadata update is merged
// onto the running snapshot. It may be called from either the
// server-state reader goroutine (immediate updates) or the metadata
// apply-loop goroutine (timestamp-deferred updates), and is invoked
// while an internal mutex is held — callbacks must not block on
// other Receiver methods. Implementations should serialize their
// own state if needed.
OnMetadata func(Metadata)
OnStreamStart func(audio.Format)
OnStreamEnd func()
OnError func(error)
// OnControl is invoked for each server/command (volume, mute) received
// from the server. Runs on a dedicated goroutine; must not block.
OnControl func(protocol.PlayerCommand)
}
type ReceiverStats struct {
Received int64
Played int64
Dropped int64
BufferDepth int
SyncRTT int64
SyncQuality sync.Quality
}
// Receiver handles connection, clock sync, decoding, and scheduling.
// It emits decoded, time-stamped audio buffers via the Output() channel.
type Receiver struct {
config ReceiverConfig
client *protocol.Client
clockSync *sync.ClockSync
scheduler *Scheduler
decoder decode.Decoder
format audio.Format
output chan audio.Buffer
ctx context.Context
cancel context.CancelFunc
schedulerCtx context.Context
schedulerCancel context.CancelFunc
serverAddr string
connected bool
// Metadata merge state. mergedMetadata is the running snapshot fed to
// OnMetadata; pendingMetadata holds future-dated updates sorted by
// ascending Timestamp until clockNow() crosses each one.
metadataMu stdsync.Mutex
mergedMetadata Metadata
pendingMetadata []*protocol.MetadataState
// clockNow returns "current server time in microseconds". Indirected
// from r.clockSync.ServerMicrosNow so tests can drive the
// timestamp-deferral path with a fake clock.
clockNow func() int64
// closeOnce guards Close so it is idempotent: in server-initiated mode
// a listener may evict a session via CloseConnection (which closes the
// receiver) while Accept's own deferred Close also fires. Without this
// the second close(r.output) panics.
closeOnce stdsync.Once
}
// NewReceiver creates a new Receiver with the given configuration.
// ServerAddr is required; other fields have defaults.
func NewReceiver(config ReceiverConfig) (*Receiver, error) {
if config.ServerAddr == "" {
return nil, fmt.Errorf("ReceiverConfig.ServerAddr is required")
}
if config.BufferMs == 0 {
config.BufferMs = 500
}
if config.BufferCapacity == 0 {
config.BufferCapacity = 1048576 // 1MB default
}
if config.DeviceInfo.ProductName == "" {
config.DeviceInfo.ProductName = "Sendspin Player"
}
if config.DeviceInfo.Manufacturer == "" {
config.DeviceInfo.Manufacturer = "Sendspin"
}
if config.DeviceInfo.SoftwareVersion == "" {
config.DeviceInfo.SoftwareVersion = "1.3.0"
}
ctx, cancel := context.WithCancel(context.Background())
clockSync := sync.NewClockSync()
r := &Receiver{
config: config,
clockSync: clockSync,
output: make(chan audio.Buffer, 10),
ctx: ctx,
cancel: cancel,
serverAddr: config.ServerAddr,
}
r.clockNow = r.clockSync.ServerMicrosNow
return r, nil
}
// Output returns the channel that emits decoded, time-stamped audio buffers.
func (r *Receiver) Output() <-chan audio.Buffer {
return r.output
}
// ClockSync returns the clock synchronization instance used by this Receiver.
func (r *Receiver) ClockSync() *sync.ClockSync {
return r.clockSync
}
// Done returns a channel that is closed when the receiver's context is
// cancelled — either by Close() or by watchConnection detecting a dropped
// protocol client. Callers can use this to implement reconnect loops.
func (r *Receiver) Done() <-chan struct{} {
return r.ctx.Done()
}
// Stats returns current pipeline statistics from the scheduler and clock sync.
func (r *Receiver) Stats() ReceiverStats {
stats := ReceiverStats{}
if r.scheduler != nil {
s := r.scheduler.Stats()
stats.Received = s.Received
stats.Played = s.Played
stats.Dropped = s.Dropped
stats.BufferDepth = r.scheduler.BufferDepth()
}
if r.clockSync != nil {
rtt, quality := r.clockSync.GetStats()
stats.SyncRTT = rtt
stats.SyncQuality = quality
}
return stats
}
// Connect establishes a client-initiated connection to the server (dials
// ServerAddr), performs initial clock sync, and starts background goroutines
// for connection watching and clock sync.
func (r *Receiver) Connect() error {
clientConfig, err := r.buildClientConfig()
if err != nil {
return err
}
r.client = protocol.NewClient(clientConfig)
if err := r.client.Connect(); err != nil {
return fmt.Errorf("connection failed: %w", err)
}
log.Printf("Connected to server: %s", r.serverAddr)
return r.startSession()
}
// AcceptConn drives the receiver protocol over an already-established
// WebSocket connection (server-initiated mode: the server discovered this
// player via mDNS and dialed it). The client still sends client/hello first
// per spec, so the handshake and message loop are identical to Connect once
// the socket exists. The caller transfers ownership of conn; Close() (or a
// dropped connection) closes it.
func (r *Receiver) AcceptConn(conn *websocket.Conn) error {
clientConfig, err := r.buildClientConfig()
if err != nil {
return err
}
r.client = protocol.NewClientFromConn(clientConfig, conn)
if err := r.client.Start(); err != nil {
return fmt.Errorf("handshake failed: %w", err)
}
log.Printf("Accepted server-initiated connection from %s", conn.RemoteAddr())
return r.startSession()
}
// buildClientConfig validates required fields and assembles the protocol
// client config (advertised formats, device info, role support) shared by
// both the client-initiated (Connect) and server-initiated (AcceptConn) paths.
func (r *Receiver) buildClientConfig() (protocol.Config, error) {
if r.config.ClientID == "" {
return protocol.Config{}, fmt.Errorf("ReceiverConfig.ClientID is required (resolve via sendspin.ResolveClientID)")
}
supportedFormats := buildSupportedFormats(r.config.PreferredCodec, r.config.MaxSampleRate, r.config.MaxBitDepth)
logAdvertisedFormats(supportedFormats, r.config.MaxSampleRate, r.config.MaxBitDepth)
return protocol.Config{
ServerAddr: r.serverAddr,
ClientID: r.config.ClientID,
Name: r.config.PlayerName,
Version: 1,
DeviceInfo: protocol.DeviceInfo{
ProductName: r.config.DeviceInfo.ProductName,
Manufacturer: r.config.DeviceInfo.Manufacturer,
SoftwareVersion: r.config.DeviceInfo.SoftwareVersion,
},
PlayerV1Support: protocol.PlayerV1Support{
SupportedFormats: supportedFormats,
BufferCapacity: r.config.BufferCapacity,
SupportedCommands: []string{"volume", "mute"},
},
ArtworkV1Support: &protocol.ArtworkV1Support{
Channels: []protocol.ArtworkChannel{
{Source: "album", Format: "jpeg", MediaWidth: 600, MediaHeight: 600},
},
},
VisualizerV1Support: &protocol.VisualizerV1Support{
BufferCapacity: r.config.BufferCapacity,
},
}, nil
}
// startSession marks the receiver connected, runs the initial clock-sync
// burst, and launches the background goroutines. Shared by Connect and
// AcceptConn; r.client must already be started.
func (r *Receiver) startSession() error {
r.connected = true
if err := r.performInitialSync(); err != nil {
log.Printf("Initial clock sync failed: %v", err)
}
go r.watchConnection()
go r.clockSyncLoop()
go r.handleStreamStart()
go r.handleStreamClear()
go r.handleStreamEnd()
go r.handleAudioChunks()
go r.handleServerState()
go r.handleGroupUpdates()
go r.handleControl()
go r.metadataApplyLoop()
return nil
}
func (r *Receiver) handleStreamStart() {
for {
select {
case start := <-r.client.StreamStart:
if start.Player == nil {
log.Printf("Received stream/start with no player info")
continue
}
log.Printf("Stream starting: %s %dHz %dch %dbit",
start.Player.Codec, start.Player.SampleRate, start.Player.Channels, start.Player.BitDepth)
format := audio.Format{
Codec: start.Player.Codec,
SampleRate: start.Player.SampleRate,
Channels: start.Player.Channels,
BitDepth: start.Player.BitDepth,
}
if start.Player.CodecHeader != "" {
headerBytes, err := base64.StdEncoding.DecodeString(start.Player.CodecHeader)
if err != nil {
log.Printf("Failed to decode codec_header: %v", err)
} else {
format.CodecHeader = headerBytes
}
}
var decoder decode.Decoder
var err error
if r.config.DecoderFactory != nil {
decoder, err = r.config.DecoderFactory(format)
} else {
decoder, err = r.defaultDecoder(format)
}
if err != nil {
r.notifyError(fmt.Errorf("failed to create decoder: %w", err))
continue
}
r.decoder = decoder
r.format = format
if r.config.OnStreamStart != nil {
r.config.OnStreamStart(format)
}
if r.schedulerCancel != nil {
r.schedulerCancel()
}
if r.scheduler != nil {
r.scheduler.Stop()
}
r.schedulerCtx, r.schedulerCancel = context.WithCancel(r.ctx)
r.scheduler = NewScheduler(r.clockSync, r.config.BufferMs, r.config.StaticDelayMs)
go r.scheduler.Run()
go r.pumpSchedulerOutput(r.schedulerCtx)
case <-r.ctx.Done():
return
}
}
}
func (r *Receiver) defaultDecoder(format audio.Format) (decode.Decoder, error) {
switch format.Codec {
case "pcm":
return decode.NewPCM(format)
case "opus":
return decode.NewOpus(format)
case "flac":
return decode.NewFLAC(format)
default:
return nil, fmt.Errorf("unsupported codec: %s", format.Codec)
}
}
func (r *Receiver) handleAudioChunks() {
for {
select {
case chunk := <-r.client.AudioChunks:
if r.decoder == nil || r.scheduler == nil {
continue
}
pcm, err := r.decoder.Decode(chunk.Data)
if err != nil {
r.notifyError(fmt.Errorf("decode error: %w", err))
continue
}
if len(pcm) == 0 {
continue
}
buf := audio.Buffer{
Timestamp: chunk.Timestamp,
Samples: pcm,
Format: r.format,
}
r.scheduler.Schedule(buf)
case <-r.ctx.Done():
return
}
}
}
func (r *Receiver) pumpSchedulerOutput(ctx context.Context) {
for {
select {
case buf := <-r.scheduler.Output():
select {
case r.output <- buf:
case <-ctx.Done():
return
}
case <-ctx.Done():
return
}
}
}
func (r *Receiver) handleStreamClear() {
for {
select {
case clear := <-r.client.StreamClear:
log.Printf("Stream clear received for roles: %v", clear.Roles)
if len(clear.Roles) == 0 || containsRole(clear.Roles, "player") {
if r.scheduler != nil {
r.scheduler.Clear()
}
}
case <-r.ctx.Done():
return
}
}
}
func (r *Receiver) handleStreamEnd() {
for {
select {
case end := <-r.client.StreamEnd:
log.Printf("Stream end received for roles: %v", end.Roles)
if len(end.Roles) == 0 || containsRole(end.Roles, "player") {
if r.config.OnStreamEnd != nil {
r.config.OnStreamEnd()
}
}
case <-r.ctx.Done():
return
}
}
}
// handleServerState reads server/state messages from the protocol client
// and feeds metadata updates into the merge layer. Non-metadata fields
// of ServerStateMessage are not used today; if/when they grow handlers
// they should branch off here.
func (r *Receiver) handleServerState() {
for {
select {
case state := <-r.client.ServerState:
if state.Metadata != nil {
r.enqueueMetadata(state.Metadata)
}
case <-r.ctx.Done():
return
}
}
}
// enqueueMetadata accepts a server metadata update and either applies it
// immediately (timestamp <= current server time, or zero timestamp) or
// queues it for future application sorted by ascending timestamp.
//
// Zero / negative timestamps apply immediately. Per spec, MetadataState
// always carries a timestamp, but defending against malformed servers
// costs nothing and keeps existing snapshot-style emitters working.
func (r *Receiver) enqueueMetadata(m *protocol.MetadataState) {
r.metadataMu.Lock()
defer r.metadataMu.Unlock()
serverNow := r.clockNow()
if m.Timestamp <= 0 || m.Timestamp <= serverNow {
r.applyMetadataLocked(m)
return
}
// Insertion-sort into pendingMetadata by ascending Timestamp.
idx := sort.Search(len(r.pendingMetadata), func(i int) bool {
return r.pendingMetadata[i].Timestamp >= m.Timestamp
})
r.pendingMetadata = append(r.pendingMetadata, nil)
copy(r.pendingMetadata[idx+1:], r.pendingMetadata[idx:])
r.pendingMetadata[idx] = m
}
// applyMetadataLocked merges the update onto mergedMetadata per tristate
// rules and fires OnMetadata. Caller must hold r.metadataMu.
//
// For each field: if the wire key was absent, preserve the prior value;
// if present and null (pointer is nil after decode), reset to zero; if
// present with a value, replace. The progress field is atomic per spec —
// a non-null progress always carries all three fields, so we either take
// TrackDuration or zero Duration.
func (r *Receiver) applyMetadataLocked(m *protocol.MetadataState) {
if m.HasField("title") {
if m.Title != nil {
r.mergedMetadata.Title = *m.Title
} else {
r.mergedMetadata.Title = ""
}
}
if m.HasField("artist") {
if m.Artist != nil {
r.mergedMetadata.Artist = *m.Artist
} else {
r.mergedMetadata.Artist = ""
}
}
if m.HasField("album") {
if m.Album != nil {
r.mergedMetadata.Album = *m.Album
} else {
r.mergedMetadata.Album = ""
}
}
if m.HasField("album_artist") {
if m.AlbumArtist != nil {
r.mergedMetadata.AlbumArtist = *m.AlbumArtist
} else {
r.mergedMetadata.AlbumArtist = ""
}
}
if m.HasField("artwork_url") {
if m.ArtworkURL != nil {
r.mergedMetadata.ArtworkURL = *m.ArtworkURL
} else {
r.mergedMetadata.ArtworkURL = ""
}
}
if m.HasField("track") {
if m.Track != nil {
r.mergedMetadata.Track = *m.Track
} else {
r.mergedMetadata.Track = 0
}
}
if m.HasField("year") {
if m.Year != nil {
r.mergedMetadata.Year = *m.Year
} else {
r.mergedMetadata.Year = 0
}
}
if m.HasField("progress") {
if m.Progress != nil {
r.mergedMetadata.Duration = m.Progress.TrackDuration / 1000
} else {
r.mergedMetadata.Duration = 0
}
}
snapshot := r.mergedMetadata
if r.config.OnMetadata != nil {
r.config.OnMetadata(snapshot)
}
}
// metadataApplyLoop drains pendingMetadata as server time crosses each
// queued update's timestamp. Started as a goroutine in Connect.
func (r *Receiver) metadataApplyLoop() {
ticker := time.NewTicker(metadataApplyTickInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
r.drainPendingMetadata()
case <-r.ctx.Done():
return
}
}
}
// drainPendingMetadata applies every pending update whose timestamp has
// elapsed, in ascending timestamp order. Pending is kept sorted by
// enqueueMetadata, so we can stop at the first future-dated entry.
func (r *Receiver) drainPendingMetadata() {
r.metadataMu.Lock()
defer r.metadataMu.Unlock()
serverNow := r.clockNow()
applied := 0
for _, m := range r.pendingMetadata {
if m.Timestamp > serverNow {
break
}
r.applyMetadataLocked(m)
applied++
}
if applied > 0 {
r.pendingMetadata = r.pendingMetadata[applied:]
}
}
func (r *Receiver) handleGroupUpdates() {
for {
select {
case update := <-r.client.GroupUpdate:
if update.PlaybackState != nil {
state := *update.PlaybackState
log.Printf("Group playback state: %s", state)
if (state == "paused" || state == "stopped") && r.scheduler != nil {
r.scheduler.Clear()
}
}
if update.GroupID != nil {
log.Printf("Joined group: %s", *update.GroupID)
}
case <-r.ctx.Done():
return
}
}
}
func (r *Receiver) handleControl() {
for {
select {
case cmd := <-r.client.ControlMsgs:
if r.config.OnControl != nil {
r.config.OnControl(cmd)
}
case <-r.ctx.Done():
return
}
}
}
// watchConnection monitors the protocol client and cancels the receiver context
// if the connection is lost, ensuring all goroutines exit cleanly.
func (r *Receiver) watchConnection() {
select {
case <-r.client.Done():
log.Printf("Server connection lost, shutting down receiver")
r.connected = false
r.notifyError(fmt.Errorf("server connection lost"))
r.cancel()
case <-r.ctx.Done():
return
}
}
// performInitialSync drives a single immediate burst so the filter has
// multiple samples before the audio scheduler starts.
func (r *Receiver) performInitialSync() error {
log.Printf("Performing initial clock synchronization (burst of %d)...", timeSyncBurstSize)
r.runTimeSyncBurst(timeSyncBurstSize)
rtt, quality := r.clockSync.GetStats()
log.Printf("Initial clock sync complete: rtt=%dus, quality=%v", rtt, quality)
return nil
}
// clockSyncLoop fires a time-sync burst every timeSyncBurstInterval. The old
// per-second single-message pattern was replaced by the burst-best strategy
// recommended by the upstream Sendspin/time-filter README and implemented by
// sendspin-cpp's TimeBurst — it converges faster and rejects high-RTT
// outliers without an explicit threshold.
func (r *Receiver) clockSyncLoop() {
ticker := time.NewTicker(timeSyncBurstInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
r.runTimeSyncBurst(timeSyncBurstSize)
case <-r.ctx.Done():
return
}
}
}
// runTimeSyncBurst sends `size` time messages back-to-back, each waiting for
// its reply, tracks the sample with the lowest RTT, and feeds only that best
// sample to the clock-sync filter at burst end. Mirrors sendspin-cpp's
// TimeBurst loop. Strictly serial — bursts run on TCP/WebSocket where a
// delayed earlier message also delays its successors, so parallel sends
// would not give independent RTT measurements.
func (r *Receiver) runTimeSyncBurst(size int) {
// Drain any responses left over from a prior burst (e.g. a timed-out
// reply that arrived after the per-message timeout fired). Keeps the
// next-message recv from picking up a stale sample.
drainLoop:
for {
select {
case <-r.client.TimeSyncResp:
default:
break drainLoop
}
}
var (
bestT1, bestT2, bestT3, bestT4 int64
bestRTT int64 = math.MaxInt64
valid = 0
)
for i := 0; i < size; i++ {
t1 := time.Now().UnixMicro()
if err := r.client.SendTimeSync(t1); err != nil {
log.Printf("Burst send %d/%d failed: %v", i+1, size, err)
continue
}
select {
case resp := <-r.client.TimeSyncResp:
t4 := time.Now().UnixMicro()
rtt := (t4 - resp.ClientTransmitted) - (resp.ServerTransmitted - resp.ServerReceived)
if rtt < bestRTT {
bestRTT = rtt
bestT1 = resp.ClientTransmitted
bestT2 = resp.ServerReceived
bestT3 = resp.ServerTransmitted
bestT4 = t4
}
valid++
case <-time.After(timeSyncResponseTimeout):
log.Printf("Burst sample %d/%d timed out", i+1, size)
case <-r.ctx.Done():
return
}
}
if valid == 0 {
log.Printf("Burst produced 0 valid samples; filter not updated")
return
}
r.clockSync.ProcessSyncResponse(bestT1, bestT2, bestT3, bestT4)
}
// buildSupportedFormats returns the player's advertised format list,
// optionally filtered by maxSampleRate / maxBitDepth (0 = no cap) and
// reordered so preferredCodec entries come first when set.
//
// Filter happens before reorder, so the surviving preferred-codec entries
// stay grouped at the head. Returns an empty slice when the caps exclude
// every format — callers can detect that and fail loudly. We do NOT
// fabricate a fallback entry: if the user asks for caps no format can
// satisfy, the honest response is empty, and the resulting handshake
// failure is the right user-visible signal.
func buildSupportedFormats(preferredCodec string, maxSampleRate, maxBitDepth int) []protocol.AudioFormat {
allFormats := []protocol.AudioFormat{
{Codec: "pcm", Channels: 2, SampleRate: 192000, BitDepth: 24},
{Codec: "pcm", Channels: 2, SampleRate: 176400, BitDepth: 24},
{Codec: "pcm", Channels: 2, SampleRate: 96000, BitDepth: 24},
{Codec: "pcm", Channels: 2, SampleRate: 88200, BitDepth: 24},
{Codec: "pcm", Channels: 2, SampleRate: 48000, BitDepth: 16},
{Codec: "pcm", Channels: 2, SampleRate: 44100, BitDepth: 16},
{Codec: "flac", Channels: 2, SampleRate: 192000, BitDepth: 24},
{Codec: "flac", Channels: 2, SampleRate: 96000, BitDepth: 24},
{Codec: "flac", Channels: 2, SampleRate: 48000, BitDepth: 24},
{Codec: "flac", Channels: 2, SampleRate: 44100, BitDepth: 16},
{Codec: "opus", Channels: 2, SampleRate: 48000, BitDepth: 16},
}
filtered := make([]protocol.AudioFormat, 0, len(allFormats))
for _, f := range allFormats {
if maxSampleRate > 0 && f.SampleRate > maxSampleRate {
continue
}
if maxBitDepth > 0 && f.BitDepth > maxBitDepth {
continue
}
filtered = append(filtered, f)
}
if preferredCodec == "" {
return filtered
}
// Move preferred codec formats to the front while preserving original
// order within each group.
preferred := make([]protocol.AudioFormat, 0, len(filtered))
rest := make([]protocol.AudioFormat, 0, len(filtered))
for _, f := range filtered {
if f.Codec == preferredCodec {
preferred = append(preferred, f)
} else {
rest = append(rest, f)
}
}
return append(preferred, rest...)
}
// logAdvertisedFormats writes one summary line describing what the player
// is about to send in client/hello — codec set, max rate, max depth, and
// the cap that produced the list. Operators reading the log can answer
// "what did this player say it could do?" without having to inspect server
// traces.
//
// Empty list goes out as a WARNING: the resulting handshake produces only
// a generic negotiation failure, so surfacing the cause player-side saves
// users from chasing the same symptom on the server.
func logAdvertisedFormats(formats []protocol.AudioFormat, maxSampleRate, maxBitDepth int) {
capDesc := "no cap"
if maxSampleRate > 0 || maxBitDepth > 0 {
capDesc = fmt.Sprintf("cap %dHz/%d-bit", maxSampleRate, maxBitDepth)
}
if len(formats) == 0 {
log.Printf("WARNING: advertising 0 supported formats (%s) — handshake will fail; relax the caps", capDesc)
return
}
codecsSeen := make(map[string]struct{}, 3)
codecsOrdered := make([]string, 0, 3)
var maxRate, maxDepth int
for _, f := range formats {
if _, ok := codecsSeen[f.Codec]; !ok {
codecsSeen[f.Codec] = struct{}{}
codecsOrdered = append(codecsOrdered, f.Codec)
}
if f.SampleRate > maxRate {
maxRate = f.SampleRate
}
if f.BitDepth > maxDepth {
maxDepth = f.BitDepth
}
}
log.Printf("Advertising %d supported formats: codecs=[%s] max=%dHz/%d-bit (%s)",
len(formats), strings.Join(codecsOrdered, ","), maxRate, maxDepth, capDesc)
}
func (r *Receiver) notifyError(err error) {
if r.config.OnError != nil {
r.config.OnError(err)
} else {
log.Printf("Receiver error: %v", err)
}
}
func (r *Receiver) Close() error {
r.closeOnce.Do(func() {
// Send goodbye BEFORE cancelling the context so the message
// reaches the server while the connection is still alive.
if r.client != nil {
r.client.SendGoodbye("shutdown")
}
r.cancel()
if r.client != nil {
r.client.Close()
}
if r.scheduler != nil {
r.scheduler.Stop()
}
if r.decoder != nil {
if err := r.decoder.Close(); err != nil {
log.Printf("Receiver: decoder close error: %v", err)
}
}
close(r.output)
})
return nil
}