🎉 live server seems to be working now

This commit is contained in:
2026-05-14 14:29:57 +02:00
commit d72e439fd9
181 changed files with 47406 additions and 0 deletions

View File

@@ -0,0 +1,53 @@
{
"permissions": {
"allow": [
"WebFetch(domain:raw.githubusercontent.com)",
"Bash(curl -s \"https://api.github.com/repos/Sendspin/sendspin-go/git/trees/main?recursive=1\")",
"Bash(curl -s https://raw.githubusercontent.com/Sendspin/sendspin-go/refs/heads/main/pkg/sendspin/player.go)",
"Bash(curl -s https://raw.githubusercontent.com/Sendspin/sendspin-go/refs/heads/main/pkg/sendspin/server.go)",
"Bash(curl -s https://raw.githubusercontent.com/Sendspin/sendspin-go/refs/heads/main/pkg/audio/output/output.go)",
"Bash(curl -s https://raw.githubusercontent.com/Sendspin/sendspin-go/refs/heads/main/pkg/sendspin/source.go)",
"Bash(curl -s https://raw.githubusercontent.com/Sendspin/sendspin-go/refs/heads/main/go.mod)",
"Bash(go version *)",
"Bash(go get *)",
"Bash(GOFLAGS=\"\" go get github.com/Sendspin/sendspin-go@main)",
"Bash(go env *)",
"Bash(GONOSUMCHECK=* GONOSUMDB=* GOFLAGS='' go get github.com/Sendspin/sendspin-go@v0.0.0-20250503162011-af11b2c17c62)",
"Bash(GOPROXY=direct go list -m -versions github.com/Sendspin/sendspin-go)",
"Bash(GOPROXY=direct go list -m -json github.com/Sendspin/sendspin-go@latest)",
"Bash(GOPROXY=direct go get github.com/Sendspin/sendspin-go@v1.7.0)",
"Bash(GOPROXY=direct go mod tidy)",
"Bash(go build *)",
"Bash(sudo pacman -S --noconfirm opus opusfile)",
"Bash(./client --help)",
"Bash(./server --help)",
"Bash(go run *)",
"Bash(make -C /home/ficik/Workspaces/Personal/rpi-sendspin)",
"Bash(pacman -Q)",
"Bash(docker build *)",
"Bash(docker run *)",
"Bash(grep -n 'ResolveClientID\\\\|PersistFn' /home/ficik/go/pkg/mod/github.com/\\\\!sendspin/sendspin-go@v1.7.0/pkg/sendspin/*.go)",
"Read(//home/ficik/go/pkg/mod/github.com/!sendspin/sendspin-go@v1.7.0/pkg/sendspin/**)",
"WebFetch(domain:github.com)",
"WebFetch(domain:api.github.com)",
"Bash(find ~ -path \"*sendspin-go*\" -name \"*.go\" 2>/dev/null | head -30; echo \"---\"; cat /home/ficik/Workspaces/Personal/rpi-sendspin/go.mod)",
"Read(//home/ficik/**)",
"Bash(go list *)",
"Bash(cp -r \"/home/ficik/go/pkg/mod/github.com/!sendspin/sendspin-go@v1.7.0\" /home/ficik/Workspaces/Personal/rpi-sendspin/third_party/sendspin-go)",
"Bash(chmod -R u+w /home/ficik/Workspaces/Personal/rpi-sendspin/third_party/sendspin-go)",
"Bash(go vet *)",
"Bash(go test *)",
"Bash(make clean *)",
"Bash(make build *)",
"Bash(make live-server *)",
"Bash(./bin/sendspin-live-server --help)",
"Bash(cat)",
"Bash(rm /tmp/volume_test.go)",
"Bash(mv /tmp/volume_test.go /tmp/voltest.go)",
"Bash(rm /tmp/voltest.go)"
],
"additionalDirectories": [
"/tmp"
]
}
}

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
bin/*
/deploy.sh

63
Dockerfile.cross Normal file
View File

@@ -0,0 +1,63 @@
FROM golang:1.24-bookworm
# Enable ARM multiarch and install cross-compilers + ARM versions of CGo deps
RUN dpkg --add-architecture armhf \
&& dpkg --add-architecture arm64 \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
crossbuild-essential-armhf \
crossbuild-essential-arm64 \
pkg-config \
libasound2-dev:armhf \
libopus-dev:armhf \
libopusfile-dev:armhf \
libogg-dev:armhf \
libasound2-dev:arm64 \
libopus-dev:arm64 \
libopusfile-dev:arm64 \
libogg-dev:arm64 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN mkdir -p /out
# armhf (RPi 2/3/4 32-bit, RPi Zero 2 W 32-bit)
RUN CGO_ENABLED=1 \
CC=arm-linux-gnueabihf-gcc \
PKG_CONFIG_LIBDIR=/usr/lib/arm-linux-gnueabihf/pkgconfig \
GOOS=linux GOARCH=arm GOARM=7 \
go build -buildvcs=false -o /out/sendspin-client-armhf ./cmd/client/ \
&& CGO_ENABLED=1 \
CC=arm-linux-gnueabihf-gcc \
PKG_CONFIG_LIBDIR=/usr/lib/arm-linux-gnueabihf/pkgconfig \
GOOS=linux GOARCH=arm GOARM=7 \
go build -buildvcs=false -o /out/sendspin-server-armhf ./cmd/server/ \
&& CGO_ENABLED=1 \
CC=arm-linux-gnueabihf-gcc \
PKG_CONFIG_LIBDIR=/usr/lib/arm-linux-gnueabihf/pkgconfig \
GOOS=linux GOARCH=arm GOARM=7 \
go build -buildvcs=false -o /out/sendspin-live-server-armhf ./cmd/live-server/
# arm64 (RPi 3/4/5 64-bit)
RUN CGO_ENABLED=1 \
CC=aarch64-linux-gnu-gcc \
PKG_CONFIG_LIBDIR=/usr/lib/aarch64-linux-gnu/pkgconfig \
GOOS=linux GOARCH=arm64 \
go build -buildvcs=false -o /out/sendspin-client-arm64 ./cmd/client/ \
&& CGO_ENABLED=1 \
CC=aarch64-linux-gnu-gcc \
PKG_CONFIG_LIBDIR=/usr/lib/aarch64-linux-gnu/pkgconfig \
GOOS=linux GOARCH=arm64 \
go build -buildvcs=false -o /out/sendspin-server-arm64 ./cmd/server/ \
&& CGO_ENABLED=1 \
CC=aarch64-linux-gnu-gcc \
PKG_CONFIG_LIBDIR=/usr/lib/aarch64-linux-gnu/pkgconfig \
GOOS=linux GOARCH=arm64 \
go build -buildvcs=false -o /out/sendspin-live-server-arm64 ./cmd/live-server/
CMD ["sh", "-c", "cp /out/* /binaries/"]

35
Makefile Normal file
View File

@@ -0,0 +1,35 @@
BINDIR := bin
CLIENT := $(BINDIR)/sendspin-client
SERVER := $(BINDIR)/sendspin-server
LIVE_SERVER := $(BINDIR)/sendspin-live-server
CROSS_IMAGE := rpi-sendspin-cross
.PHONY: all build client server live-server rpi test vet clean
all: build
build: client server live-server
client:
go build -o $(CLIENT) ./cmd/client/
server:
go build -o $(SERVER) ./cmd/server/
live-server:
go build -o $(LIVE_SERVER) ./cmd/live-server/
test:
go test ./...
vet:
go vet ./...
# Cross-compile for RPi via Docker (armhf + arm64)
rpi:
docker build --network=host -f Dockerfile.cross -t $(CROSS_IMAGE) .
mkdir -p $(BINDIR)
docker run --rm --network=host -v $(CURDIR)/$(BINDIR):/binaries $(CROSS_IMAGE)
clean:
rm -rf $(BINDIR)

114
README.md Normal file
View File

@@ -0,0 +1,114 @@
# rpi-sendspin
A [sendspin](https://github.com/Sendspin/sendspin-go) client and server with native PipeWire node selection.
Audio is routed through `pw-cat`, so any PipeWire source or sink can be targeted by name — no ALSA/PulseAudio compatibility layer needed.
## Requirements
- Go 1.22+
- PipeWire with `pw-cat` and `pw-dump` in `$PATH`
- `libopus` and `libopusfile` (for the sendspin dependency)
**Build host** (dev packages, only needed to compile):
```bash
# Arch
sudo pacman -S pipewire opus opusfile
# Debian/Ubuntu
sudo apt install libopus-dev libopusfile-dev libasound2-dev
```
**Raspberry Pi** (runtime only, no dev packages needed):
```bash
sudo apt install libopus0 libopusfile0 libasound2 pipewire
```
## Build
```bash
make # builds bin/sendspin-client and bin/sendspin-server (native)
make rpi # cross-compiles for RPi via Docker → bin/*-armhf and bin/*-arm64
make clean
```
`make rpi` requires Docker. It builds a Debian image with ARM cross-compilers and the ARM versions of libopus/libasound, then copies the four binaries into `bin/`. No toolchain installation needed on the host.
## Usage
### Server
Captures audio from a PipeWire source node and streams it to connected clients.
```bash
# List available capture sources
./bin/sendspin-server --list-sources
# Stream from a specific source
./bin/sendspin-server --source alsa_input.usb-JBL_Quantum810-00.pro-input-0
# Options
./bin/sendspin-server --help
-source string PipeWire source node name
-port int listen port (default 8927)
-name string server name (default "Sendspin Server")
-rate int capture sample rate (default 48000)
-channels int capture channels (default 2)
```
### Client
Connects to a sendspin server and plays audio on a PipeWire sink.
```bash
# List available playback sinks
./bin/sendspin-client --list-devices
# Play on a specific sink (omit --device to use the system default)
./bin/sendspin-client --server rpi.local:8927 --device alsa_output.usb-JBL_Quantum810-00.pro-output-0
# Options
./bin/sendspin-client --help
-server string server address (default "localhost:8927")
-device string PipeWire sink node name (default: system default)
-name string player name (default: hostname)
-volume int initial volume 0-100 (default 80)
```
## Running as a systemd user service
Example unit files are in [dist/systemd/](dist/systemd/). Both run as a **user** service so they have access to the PipeWire session without any special privileges.
```bash
# 1. Copy the binary
install -Dm755 bin/sendspin-client-arm64 ~/.local/bin/sendspin-client
# 2. Edit the unit file — set --server, --device, --name to your values
cp dist/systemd/sendspin-client.service ~/.config/systemd/user/
# 3. Enable and start
systemctl --user daemon-reload
systemctl --user enable --now sendspin-client
# Logs
journalctl --user -u sendspin-client -f
```
The unit files use `%H` (hostname) for `--name` and `%h` (home dir) for the binary path. Replace the placeholder `--device` / `--source` values — use `--list-devices` / `--list-sources` to find the right node name first.
## Architecture
```
PipeWire source node
pw-cat --capture
│ int32 PCM
sendspin server ──── network ──── sendspin client
│ int32 PCM
pw-cat --playback
PipeWire sink node
```
`internal/pipewire/source.go` wraps `pw-cat --capture` as an `AudioSource`.
`internal/pipewire/output.go` wraps `pw-cat --playback` as an `Output`.
`internal/pipewire/nodes.go` parses `pw-dump` JSON to enumerate nodes.

752
README.md.1 Normal file
View File

@@ -0,0 +1,752 @@
# The Sendspin Protocol
_This is raw, unfiltered and experimental._
Sendspin is a multi-room music experience protocol. The goal of the protocol is to orchestrate all devices that make up the music listening experience. This includes outputting audio on multiple speakers simultaneously, screens and lights visualizing the audio or album art, and wall tablets providing media controls.
## Definitions
- **Sendspin Server** - orchestrates all devices, generates audio streams, manages players and clients, provides metadata
- **Sendspin Client** - a client that can play audio, visualize audio, display metadata, display colors, or provide music controls. Has different possible roles (player, metadata, controller, artwork, visualizer, color). Every client has a unique identifier
- **Player** - receives audio and plays it in sync. Has its own volume and mute state and preferred format settings
- **Controller** - controls the Sendspin group this client is part of
- **Metadata** - displays text metadata (title, artist, album, etc.)
- **Artwork** - displays artwork images. Has preferred format for images
- **Visualizer** - visualizes music. Has preferred format for audio features
- **Color** - receives colors derived from the current audio
- **Sendspin Group** - a group of clients. Each client belongs to exactly one group, and every group has at least one client. Every group has a unique identifier. Each group has the following states: list of member clients, volume, mute, and playback state
- **Sendspin Stream** - client-specific details on how the server is formatting and sending binary data. Each role's stream is managed separately. Each client receives its own independently encoded stream based on its capabilities and preferences. For players, the server sends audio chunks as far ahead as the client's buffer capacity allows. For artwork clients, the server sends album artwork and other visual images through the stream
## Role Versioning
Roles define what capabilities and responsibilities a client has. All roles use explicit versioning with the `@` character: `<role>@<version>` (e.g., `player@v1`, `controller@v1`).
This specification defines the following roles: [`player`](#player-messages), [`controller`](#controller-messages), [`metadata`](#metadata-messages), [`artwork`](#artwork-messages), [`visualizer`](#visualizer-messages), [`color`](#color-messages). All servers must implement all versions of these roles described in this specification.
All role names and versions not starting with `_` are reserved for future revisions of this specification.
### Priority and Activation
Clients list roles in `supported_roles` in priority order (most preferred first). If a client supports multiple versions of a role, all should be listed: `["player@v2", "player@v1"]`.
The server activates one version per role family (e.g., one `player@vN`, one `controller@vN`)—the first match it implements from the client's list. The server reports activated roles in `active_roles`.
Message object keys (e.g., `player?`, `controller?`) use unversioned role names. The server determines the appropriate version from the client's `active_roles`.
### Detecting Outdated Servers
Servers should track when clients request roles or role versions they don't implement (excluding those starting with `_`). This indicates the client supports a newer version of the specification and the server needs to be updated.
### Application-Specific Roles
Custom roles outside the specification start with `_` (e.g., `_myapp_controller`, `_custom_display`). Application-specific roles can also be versioned: `_myapp_visualizer@v2`.
## Establishing a Connection
Sendspin has two standard ways to establish connections: Server and Client initiated. Server Initiated connections are recommended as they provide standardized multi-server behavior, but require mDNS which may not be available in all environments.
Sendspin Servers must support both methods described below.
### Server Initiated Connections
Clients announce their presence via mDNS using:
- Service type: `_sendspin._tcp.local.`
- Port: The port the Sendspin client is listening on (recommended: `8928`)
- TXT record: `path` key specifying the WebSocket endpoint (recommended: `/sendspin`)
- TXT record: `name` key specifying the friendly name of the player (optional)
The server discovers available clients through mDNS and connects to each client via WebSocket using the advertised address and path.
**Note:** Do not manually connect to servers if you are advertising `_sendspin._tcp`.
#### Multiple Servers
In environments with multiple Sendspin servers, servers may need to reconnect to clients when starting playback to reclaim them. The [`server/hello`](#server--client-serverhello) message includes a `connection_reason` field indicating whether the server is connecting for general availability (`'discovery'`) or for active/upcoming playback (`'playback'`).
Clients can only be connected to one server at a time. Clients must persistently store the `server_id` of the server that most recently had `playback_state: 'playing'` (the "last played server").
When a second server connects, clients must:
1. **Accept incoming connections**: Complete the handshake (send [`client/hello`](#client--server-clienthello), receive [`server/hello`](#server--client-serverhello)) with the new server before making any decisions.
2. **Decide which server to keep**:
- If the new server's `connection_reason` is `'playback'` → switch to new server
- If the new server's `connection_reason` is `'discovery'` and the existing server connected with `'playback'` → keep existing server
- If both servers have `connection_reason: 'discovery'`:
- Prefer the server matching the stored last played server
- If neither matches (or no history), keep the existing server
3. **Disconnect**: Send [`client/goodbye`](#client--server-clientgoodbye) with reason `'another_server'` to the server being disconnected, then close the connection.
### Client Initiated Connections
If clients prefer to initiate the connection instead of waiting for the server to connect, the server must be discoverable via mDNS using:
- Service type: `_sendspin-server._tcp.local.`
- Port: The port the Sendspin server is listening on (recommended: `8927`)
- TXT record: `path` key specifying the WebSocket endpoint (recommended: `/sendspin`)
- TXT record: `name` key specifying the friendly name of the server (optional)
Clients discover the server through mDNS and initiate a WebSocket connection using the advertised address and path.
**Note:** Do not advertise `_sendspin._tcp` if the client plans to initiate the connection.
#### Multiple Servers
Unlike server-initiated connections, servers cannot reclaim clients by reconnecting. How clients handle multiple discovered servers, server selection, and switching is implementation-defined.
**Note:** After this point, Sendspin works independently of how the connection was established. The Sendspin client is always the consumer of data like audio or metadata, regardless of who initiated the connection.
While custom connection methods are possible for specialized use cases (like remotely accessible web-browsers, mobile apps), most clients should use one of the two standardized methods above if possible.
## Communication
Once the connection is established, Client and Server are going to talk.
The first message must always be a `client/hello` message from the client to the server.
Once the server receives this message, it responds with a `server/hello` message. Before this handshake is complete, no other messages should be sent.
WebSocket text messages are used to send JSON payloads.
**Note:** In field definitions, `?` indicates an optional field (e.g., `field?`: type means the field may be omitted).
All messages have a `type` field identifying the message and a `payload` object containing message-specific data. The payload structure varies by message type and is detailed in each message section below.
Message format example:
```json
{
"type": "stream/start",
"payload": {
"player": {
"codec": "opus",
"sample_rate": 48000,
"channels": 2,
"bit_depth": 16
},
"artwork": {
"channels": [
{
"source": "album",
"format": "jpeg",
"width": 800,
"height": 800
}
]
}
}
}
```
WebSocket binary messages are used to send audio chunks, media art, and visualization data. The first byte is a uint8 representing the message type.
### Binary Message ID Structure
Binary message IDs typically use **bits 7-2** for role type and **bits 1-0** for message slot, allocating 4 IDs per role. Roles with expanded allocations use **bits 2-0** for message slot (8 IDs).
**Role assignments:**
- `000000xx` (0-3): Reserved for future use
- `000001xx` (4-7): Player role
- `000010xx` (8-11): Artwork role
- `000011xx` (12-15): Reserved for a future role
- `00010xxx` (16-23): Visualizer role
- Roles 6-47 (IDs 24-191): Reserved for future roles
- Roles 48-63 (IDs 192-255): Available for use by [application-specific roles](#application-specific-roles)
**Message slots:**
- Slot 0: `xxxxxx00`
- Slot 1: `xxxxxx01`
- Slot 2: `xxxxxx10`
- Slot 3: `xxxxxx11`
Roles with expanded allocations have slots 0-7.
**Note:** Role versions share the same binary message IDs (e.g., `player@v1` and `player@v2` both use IDs 4-7).
## Clock Synchronization
Clients continuously send `client/time` messages to maintain an accurate offset from the server's clock. The frequency of these messages is determined by the client based on network conditions and clock stability.
Binary audio messages contain timestamps in the server's time domain indicating when the audio should be played. Clients must use the [time-filter](https://github.com/Sendspin-Protocol/time-filter) algorithm to translate server timestamps to their local clock for synchronized playback. The time filter is a two-dimensional Kalman filter that tracks both clock offset and drift. See the [time-filter](https://github.com/Sendspin-Protocol/time-filter) repository for a C++ reference implementation and [aiosendspin](https://github.com/Sendspin-Protocol/aiosendspin/blob/main/aiosendspin/client/time_sync.py) for a Python implementation.
Each [`server/time`](#server--client-servertime) response provides the four timestamps needed by the filter: the client's transmitted timestamp, the server's received timestamp, the server's transmitted timestamp, and the client's receive time (captured locally when the response arrives). Clients feed these into the time filter via its `update` method and use its `compute_client_time` method to convert server timestamps to local clock values for playback scheduling.
## Playback Synchronization
- Each client is responsible for maintaining synchronization with the server's timestamps
- Clients maintain accurate sync by adding or removing samples using interpolation to compensate for clock drift
- When a client cannot maintain sync (e.g., buffer underrun), it should send `state: 'error'` via [`client/state`](#client--server-clientstate), mute its audio output, and continue buffering until it can resume synchronized playback, at which point it should send `state: 'synchronized'`
- The server is unaware of individual client synchronization accuracy - it simply broadcasts timestamped audio
- The server sends audio to late-joining clients with future timestamps only, allowing them to buffer and start playback in sync with existing clients
- Audio chunks may arrive with timestamps in the past due to network delays or buffering; clients should drop these late chunks to maintain sync
- Clients subtract their [`static_delay_ms`](#client--server-clientstate-player-object) from server timestamps before scheduling playback
- Servers factor in each client's `static_delay_ms` when calculating how far ahead to send audio, keeping effective buffer headroom constant
```mermaid
sequenceDiagram
participant Client
participant Server
Note over Client,Server: WebSocket connection established
Note over Client,Server: Text messages = JSON payloads, Binary messages = Audio/Art/Visualization
Client->>Server: client/hello (roles and capabilities)
Server->>Client: server/hello (server info, connection_reason)
Client->>Server: client/state (state: synchronized)
alt Player role
Client->>Server: client/state (player: volume, muted)
end
loop Continuous clock sync
Client->>Server: client/time (client clock)
Server->>Client: server/time (timing + offset info)
end
alt Stream starts
Server->>Client: stream/start (codec, format details)
end
Server->>Client: group/update (playback_state, group_id, group_name)
Server->>Client: server/state (metadata, controller, color)
loop During playback
alt Player role
Server->>Client: binary Type 4 (audio chunks with timestamps)
end
alt Artwork role
Server->>Client: binary Types 8-11 (artwork channels 0-3)
end
alt Visualizer role
Server->>Client: binary Type 16 (visualization data)
end
end
alt Player requests format change
Client->>Server: stream/request-format (codec, sample_rate, etc)
Server->>Client: stream/start (player: new format)
end
alt Seek operation
Server->>Client: stream/clear (roles: [player, visualizer])
end
alt Controller role
Client->>Server: client/command (controller: play/pause/volume/switch/etc)
end
alt State changes
Client->>Server: client/state (state and/or player changes)
end
alt Server commands player
Server->>Client: server/command (player: volume, mute)
end
Server->>Client: stream/end (ends all role streams)
alt Graceful disconnect
Client->>Server: client/goodbye (reason)
Note over Client,Server: Server initiates disconnect
end
```
## Core messages
This section describes the fundamental messages that establish communication between clients and the server. These messages handle initial handshakes, ongoing clock synchronization, stream lifecycle management, and role-based state updates and commands.
Every Sendspin client and server must implement all messages in this section regardless of their specific roles. Role-specific object details are documented in their respective role sections and need to be implemented only if the client supports that role.
### Client → Server: `client/hello`
First message sent by the client after establishing the WebSocket connection. Contains information about the client's capabilities and roles.
This message will be followed by a [`server/hello`](#server--client-serverhello) message from the server.
Players that can output audio should have the role `player`.
- `client_id`: string - uniquely identifies the client for groups and de-duplication. Should remain persistent across reconnections so servers can associate clients with previous sessions (e.g., remembering group membership, settings, playback queue)
- `name`: string - friendly name of the client
- `device_info?`: object - optional information about the device
- `product_name?`: string - device model/product name
- `manufacturer?`: string - device manufacturer name
- `software_version?`: string - software version of the client (not the Sendspin version)
- `version`: integer (must be `1`) - version of the core message format that the Sendspin client implements (independent of role versions)
- `supported_roles`: string[] - versioned roles supported by the client (e.g., `player@v1`, `controller@v1`). Defined versioned roles are:
- `player@v1` - outputs audio
- `controller@v1` - controls the current Sendspin group
- `metadata@v1` - displays text metadata describing the currently playing audio
- `artwork@v1` - displays artwork images
- `visualizer@v1` - visualizes audio
- `color@v1` - receives colors derived from the current audio
- `player@v1_support?`: object - only if `player@v1` is listed ([see player@v1 support object details](#client--server-clienthello-playerv1-support-object))
- `artwork@v1_support?`: object - only if `artwork@v1` is listed ([see artwork@v1 support object details](#client--server-clienthello-artworkv1-support-object))
- `visualizer@v1_support?`: object - only if `visualizer@v1` is listed ([see visualizer@v1 support object details](#client--server-clienthello-visualizerv1-support-object))
**Note:** Each role version may have its own support object (e.g., `player@v1_support`, `player@v2_support`). Application-specific roles or role versions follow the same pattern (e.g., `_myapp_display@v1_support`, `player@_experimental_support`).
### Client → Server: `client/time`
Sends current internal clock timestamp (in microseconds) to the server.
Once received, the server responds with a [`server/time`](#server--client-servertime) message containing timing information to establish clock offsets.
- `client_transmitted`: integer - client's internal clock timestamp in microseconds
### Server → Client: `server/hello`
Response to the [`client/hello`](#client--server-clienthello) message with information about the server.
Only after receiving this message should the client send any other messages (including [`client/time`](#client--server-clienttime) and the initial [`client/state`](#client--server-clientstate) message if the client has roles that require state updates).
- `server_id`: string - identifier of the server
- `name`: string - friendly name of the server
- `version`: integer (must be `1`) - version of the core message format that the server implements (independent of role versions)
- `active_roles`: string[] - versioned roles that are active for this client (e.g., `player@v1`, `controller@v1`)
- `connection_reason`: 'discovery' | 'playback' - only used for [server-initiated connections](#multiple-servers)
- `discovery` - server is connecting for general availability (e.g., initial discovery, reconnection after connection loss)
- `playback` - server needs client for active or upcoming playback
**Note:** Servers will always activate the client's [preferred](#priority-and-activation) version of each role. Checking `active_roles` is only necessary to detect outdated servers or confirm activation of [application-specific roles](#application-specific-roles).
### Server → Client: `server/time`
Response to the [`client/time`](#client--server-clienttime) message with timestamps to establish clock offsets.
For synchronization, all timing is relative to the server's monotonic clock. These timestamps have microsecond precision and are not necessarily based on epoch time.
- `client_transmitted`: integer - client's internal clock timestamp received in the `client/time` message
- `server_received`: integer - timestamp that the server received the `client/time` message in microseconds
- `server_transmitted`: integer - timestamp that the server transmitted this message in microseconds
### Client → Server: `client/state`
Client sends state updates to the server. Contains client-level state and role-specific state objects.
Must be sent immediately after receiving [`server/hello`](#server--client-serverhello), and whenever any state changes thereafter.
For the initial message, include all state fields. For subsequent updates, only include fields that have changed. The server will merge these updates into existing state.
- `state`: 'synchronized' | 'error' | 'external_source' - operational state of the client
- `'synchronized'` - client is operational and synchronized with server timestamps
- `'error'` - client has a problem preventing normal operation (unable to keep up, clock sync issues, etc.)
- `'external_source'` - client is in use by an external system and is not currently participating in Sendspin playback with this server. See [External Source Handling](#external-source-handling)
- `player?`: object - only if client has `player` role ([see player state object details](#client--server-clientstate-player-object))
[Application-specific roles](#application-specific-roles) may also include objects in this message (keys starting with `_`).
### External Source Handling
When a client sets `state: 'external_source'`, it indicates the client's output is in use by an external system (e.g., a different audio source, HDMI input, or local media playback) and is not currently participating in Sendspin playback with this server.
#### Server behavior when `state` changes to `'external_source'`:
If the client is in a multi-client group:
1. Remember the client's current group as its "previous group" (see [switch command cycle](#switch-command-cycle))
2. Move the client to a new solo group (stopped)
- Send [`group/update`](#server--client-groupupdate) with the new group information
- Send [`stream/end`](#server--client-streamend) for all active streams
If the client is already in a solo group:
- Stop playback and send [`stream/end`](#server--client-streamend) for all active streams
### Client → Server: `client/command`
Client sends commands to the server. Contains command objects based on the client's supported roles.
- `controller?`: object - only if client has `controller` role ([see controller command object details](#client--server-clientcommand-controller-object))
[Application-specific roles](#application-specific-roles) may also include objects in this message (keys starting with `_`).
### Server → Client: `server/state`
Server sends state updates to the client. Contains role-specific state objects.
Only include fields that have changed. The client will merge these updates into existing state. Fields set to `null` should be cleared from the client's state.
- `metadata?`: object - only sent to clients with `metadata` role ([see metadata state object details](#server--client-serverstate-metadata-object))
- `controller?`: object - only sent to clients with `controller` role ([see controller state object details](#server--client-serverstate-controller-object))
- `color?`: object - only sent to clients with `color` role ([see color state object details](#server--client-serverstate-color-object))
[Application-specific roles](#application-specific-roles) may also include objects in this message (keys starting with `_`).
### Server → Client: `server/command`
Server sends commands to the client. Contains role-specific command objects.
- `player?`: object - only sent to clients with `player` role ([see player command object details](#server--client-servercommand-player-object))
[Application-specific roles](#application-specific-roles) may also include objects in this message (keys starting with `_`).
### Server → Client: `stream/start`
Starts a stream for one or more roles. If sent for a role that already has an active stream, updates the stream configuration without clearing buffers.
- `player?`: object - only sent to clients with the `player` role ([see player object details](#server--client-streamstart-player-object))
- `artwork?`: object - only sent to clients with the `artwork` role ([see artwork object details](#server--client-streamstart-artwork-object))
- `visualizer?`: object - only sent to clients with the `visualizer` role ([see visualizer object details](#server--client-streamstart-visualizer-object))
[Application-specific roles](#application-specific-roles) may also include objects in this message (keys starting with `_`).
### Server → Client: `stream/clear`
Instructs clients to clear buffers without ending the stream. Used for seek operations.
- `roles?`: string[] - which roles to clear: '[player](#server--client-streamclear-player)', '[visualizer](#server--client-streamclear-visualizer)', or both. If omitted, clears both roles
[Application-specific roles](#application-specific-roles) may also be included in this array (names starting with `_`).
### Client → Server: `stream/request-format`
Request different stream format (upgrade or downgrade). Available for clients with the `player` or `artwork` role.
- `player?`: object - only for clients with the `player` role ([see player object details](#client--server-streamrequest-format-player-object))
- `artwork?`: object - only for clients with the `artwork` role ([see artwork object details](#client--server-streamrequest-format-artwork-object))
[Application-specific roles](#application-specific-roles) may also include objects in this message (keys starting with `_`).
Response: [`stream/start`](#server--client-streamstart) for the requested role(s) with the new format.
**Note:** Clients should use this message to adapt to changing network conditions, CPU constraints, or display requirements. The server maintains separate encoding for each client, allowing heterogeneous device capabilities within the same group.
### Server → Client: `stream/end`
Ends the stream for one or more roles. When received, clients should stop output and clear buffers for the specified roles.
- `roles?`: string[] - roles to end streams for ('player', 'artwork', 'visualizer'). If omitted, ends all active streams
[Application-specific roles](#application-specific-roles) may also be included in this array (names starting with `_`).
### Server → Client: `group/update`
State update of the group this client is part of.
Contains delta updates with only the changed fields. The client should merge these updates into existing state. Fields set to `null` should be cleared from the client's state.
- `playback_state?`: 'playing' | 'stopped' - playback state of the group
- `group_id?`: string - group identifier
- `group_name?`: string - friendly name of the group
### Client → Server: `client/goodbye`
Sent by the client before gracefully closing the connection. This allows the client to inform the server why it is disconnecting.
Upon receiving this message, the server should initiate the disconnect.
- `reason`: 'another_server' | 'shutdown' | 'restart' | 'user_request'
- `another_server` - client is switching to a different Sendspin server. Server should not auto-reconnect but should show the client as available for future playback
- `shutdown` - client is shutting down. Server should not auto-reconnect
- `restart` - client is restarting and will reconnect. Server should auto-reconnect
- `user_request` - user explicitly requested to disconnect from this server. Server should not auto-reconnect
**Note:** Clients may close the connection without sending this message (e.g., crash, network loss), or immediately after sending `client/goodbye` without waiting for the server to disconnect. When a client disconnects without sending `client/goodbye`, servers should assume the disconnect reason is `restart` and attempt to auto-reconnect.
## Player messages
This section describes messages specific to clients with the `player` role, which handle audio output and synchronized playback. Player clients receive timestamped audio data, manage their own volume and mute state, and can request different audio formats based on their capabilities and current conditions.
**Note:** Volume values (0-100) represent perceived loudness, not linear amplitude (e.g., volume 50 should be perceived as half as loud as volume 100). Players must convert these values to appropriate amplitude for their audio hardware.
### Client → Server: `client/hello` player@v1 support object
The `player@v1_support` object in [`client/hello`](#client--server-clienthello) has this structure:
- `player@v1_support`: object
- `supported_formats`: object[] - list of supported audio formats in priority order (first is preferred)
- `codec`: 'opus' | 'flac' | 'pcm' - codec identifier
- `channels`: integer - supported number of channels (e.g., 1 = mono, 2 = stereo)
- `sample_rate`: integer - sample rate in Hz (e.g., 44100)
- `bit_depth`: integer - bit depth for this format (e.g., 16, 24)
- `buffer_capacity`: integer - max size in bytes of compressed audio messages in the buffer that are yet to be played
- `supported_commands`: string[] - subset of: 'volume', 'mute'
**Note:** Servers must support all audio codecs: 'opus', 'flac', and 'pcm'.
**PCM Encoding Convention:** For the `pcm` codec, samples are encoded as little-endian signed integers (two's complement). 24-bit samples are packed as 3 bytes per sample.
### Client → Server: `client/state` player object
The `player` object in [`client/state`](#client--server-clientstate) has this structure:
Informs the server of player-specific state changes. Only for clients with the `player` role.
State updates must be sent whenever any state changes, including when the volume was changed through a `server/command` or via device controls.
- `player`: object
- `volume?`: integer - range 0-100, must be included if 'volume' is in `supported_commands` from [`player@v1_support`](#client--server-clienthello-playerv1-support-object)
- `muted?`: boolean - mute state, must be included if 'mute' is in `supported_commands` from [`player@v1_support`](#client--server-clienthello-playerv1-support-object)
- `static_delay_ms`: integer - static delay in milliseconds (0-5000), always required for players
- `supported_commands?`: string[] - subset of: 'set_static_delay'
**Static delay:** The default is 0, meaning audio exits the device's audio port at the timestamp. `static_delay_ms` compensates for additional delay beyond the port (external speakers, amplifiers). Negative values are not supported and should never be required for any compliant implementation. Clients must persist `static_delay_ms` locally across reboots and server reconnections. Clients may update `static_delay_ms` and `supported_commands` when audio output changes (e.g., external speaker connected), persisting separate delays per output.
### Client → Server: `stream/request-format` player object
The `player` object in [`stream/request-format`](#client--server-streamrequest-format) has this structure:
- `player`: object
- `codec?`: 'opus' | 'flac' | 'pcm' - requested codec identifier
- `channels?`: integer - requested number of channels (e.g., 1 = mono, 2 = stereo)
- `sample_rate?`: integer - requested sample rate in Hz (e.g., 44100, 48000)
- `bit_depth?`: integer - requested bit depth (e.g., 16, 24)
Response: [`stream/start`](#server--client-streamstart) with the new format.
**Note:** Clients should use this message to adapt to changing network conditions or CPU constraints. The server maintains separate encoding for each client, allowing heterogeneous device capabilities within the same group.
### Server → Client: `server/command` player object
The `player` object in [`server/command`](#server--client-servercommand) has this structure:
Request the player to perform an action, e.g., change volume or mute state.
- `player`: object
- `command`: 'volume' | 'mute' | 'set_static_delay' - must be listed in `supported_commands` from [`player@v1_support`](#client--server-clienthello-playerv1-support-object) or from [`client/state`](#client--server-clientstate-player-object); unlisted commands are ignored by the client
- `volume?`: integer - volume range 0-100, only set if `command` is `volume`
- `mute?`: boolean - true to mute, false to unmute, only set if `command` is `mute`
- `static_delay_ms?`: integer - delay in milliseconds (0-5000), only set if `command` is `set_static_delay`
### Server → Client: `stream/start` player object
The `player` object in [`stream/start`](#server--client-streamstart) has this structure:
- `player`: object
- `codec`: string - codec to be used
- `sample_rate`: integer - sample rate to be used
- `channels`: integer - channels to be used
- `bit_depth`: integer - bit depth to be used
- `codec_header?`: string - Base64 encoded codec header (if necessary; e.g., FLAC)
### Server → Client: `stream/clear` player
When [`stream/clear`](#server--client-streamclear) includes the player role, clients should clear all buffered audio chunks and continue with chunks received after this message.
### Server → Client: Audio Chunks (Binary)
Binary messages should be rejected if there is no active stream.
- Byte 0: message type `4` (uint8)
- Bytes 1-8: timestamp (big-endian int64) - server clock time in microseconds when the first sample should be output
- Rest of bytes: encoded audio frame
The timestamp indicates when the first audio sample in this chunk should be output. Clients must translate this server timestamp to their local clock using the offset computed from clock synchronization, subtracting their [`static_delay_ms`](#client--server-clientstate-player-object) from the timestamp. Clients should compensate for any known processing delays (e.g., DAC latency, audio buffer delays, amplifier delays) by accounting for these delays when submitting audio to the hardware.
## Controller messages
This section describes messages specific to clients with the `controller` role, which enables the client to control the Sendspin group this client is part of, and switch between groups.
Every client which lists the `controller` role in the `supported_roles` of the `client/hello` message needs to implement all messages in this section.
### Client → Server: `client/command` controller object
The `controller` object in [`client/command`](#client--server-clientcommand) has this structure:
Control the group that's playing and switch groups. Only valid from clients with the `controller` role.
- `controller`: object
- `command`: 'play' | 'pause' | 'stop' | 'next' | 'previous' | 'volume' | 'mute' | 'repeat_off' | 'repeat_one' | 'repeat_all' | 'shuffle' | 'unshuffle' | 'switch' - should be one of the values listed in `supported_commands` from the [`server/state`](#server--client-serverstate-controller-object) `controller` object. Commands not in `supported_commands` are ignored by the server
- `volume?`: integer - volume range 0-100, only set if `command` is `volume`
- `mute?`: boolean - true to mute, false to unmute, only set if `command` is `mute`
#### Command behaviour
- 'play' - resume playback from current position. If nothing is currently playing, the server must try to resume the group's last playing media. This history should persist across server and client reboots
- 'pause' - pause playback at current position
- 'stop' - stop playback and reset position to beginning
- 'next' - skip to next track, chapter, etc.
- 'previous' - skip to previous track, chapter, restart current, etc.
- 'volume' - set group volume (requires `volume` parameter)
- 'mute' - set group mute state (requires `mute` parameter)
- 'repeat_off' - disable repeat mode
- 'repeat_one' - repeat the current track continuously
- 'repeat_all' - repeat all tracks continuously
- 'shuffle' - randomize playback order
- 'unshuffle' - restore original playback order
- 'switch' - move this client to the next group in a predefined cycle as described [below](#switch-command-cycle)
**Setting group volume:** When setting group volume via the 'volume' command, the server applies the following algorithm to preserve relative volume levels while achieving the requested volume as closely as player boundaries allow:
1. Calculate the delta: `delta = requested_volume - current_group_volume` (where current group volume is the average of all player volumes)
2. Apply the delta to each player's volume
3. Clamp any player volumes that exceed boundaries (0-100%)
4. If any players were clamped:
- Calculate the lost delta: `sum of (proposed_volume - clamped_volume)` for all clamped players
- Divide the lost delta equally among non-clamped players
- Repeat steps 1-4 until either:
- All delta has been successfully applied, or
- All players are clamped at their volume boundaries
This ensures that when setting group volume to 100%, all players will reach 100% if possible, and the final group volume matches the requested volume as closely as player boundaries allow.
**Setting group mute:** When setting group mute via the 'mute' command, the server applies the mute state to all players in the group.
#### Switch command cycle
**Previous group priority:** If the client is still in the solo group from its `'external_source'` transition, the `switch` command prioritizes rejoining the previous group.
For clients **with** the `player` role, the cycle includes:
1. Multi-client groups that are currently playing
2. Single-client groups (other players playing alone)
3. A solo group containing only this client
For clients **without** the `player` role, the cycle includes:
1. Multi-client groups that are currently playing
2. Single-client groups (other players playing alone)
### Server → Client: `server/state` controller object
The `controller` object in [`server/state`](#server--client-serverstate) has this structure:
- `controller`: object
- `supported_commands`: string[] - subset of: 'play' | 'pause' | 'stop' | 'next' | 'previous' | 'volume' | 'mute' | 'repeat_off' | 'repeat_one' | 'repeat_all' | 'shuffle' | 'unshuffle' | 'switch'
- `volume`: integer - volume of the whole group, range 0-100
- `muted`: boolean - mute state of the whole group
- `repeat`: 'off' | 'one' | 'all' - repeat mode: 'off' = no repeat, 'one' = repeat current track, 'all' = repeat all tracks (in the queue, playlist, etc.)
- `shuffle`: boolean - shuffle mode enabled/disabled
**Reading group volume:** Group volume is calculated as the average of all player volumes in the group.
**Reading group mute:** Group mute is `true` only when all players in the group are muted. If some players are muted and others are not, group mute is `false`.
## Metadata messages
This section describes messages specific to clients with the `metadata` role, which handle display of track information and playback progress. Metadata clients receive state updates with track details.
### Server → Client: `server/state` metadata object
The `metadata` object in [`server/state`](#server--client-serverstate) has this structure:
- `metadata`: object
- `timestamp`: integer - server clock time in microseconds for when this metadata is valid
- `title?`: string | null - track title
- `artist?`: string | null - primary artist(s)
- `album_artist?`: string | null - album artist(s)
- `album?`: string | null - name of the album or release that this track belongs to
- `artwork_url?`: string | null - URL to artwork image. Useful for clients that want to forward metadata to external systems or for powerful clients that can fetch and process images themselves
- `year?`: integer | null - release year in YYYY format
- `track?`: integer | null - track number on the album (1-indexed), null if unknown or not applicable
- `progress?`: object | null - playback progress information. The server must send this object whenever playback state changes (play, pause, resume, seek, playback speed change)
- `track_progress`: integer - current playback position in milliseconds since start of track
- `track_duration`: integer - total track length in milliseconds, 0 for unlimited/unknown duration (e.g., live radio streams)
- `playback_speed`: integer - playback speed multiplier * 1000 (e.g., 1000 = normal speed, 1500 = 1.5x speed, 500 = 0.5x speed, 0 = paused)
#### Calculating current track position
Clients can calculate the current track position at any time using the `timestamp` and `progress` values from the last metadata message that included the `progress` object:
```python
calculated_progress = metadata.progress.track_progress + (current_time - metadata.timestamp) * metadata.progress.playback_speed / 1000000
if metadata.progress.track_duration != 0:
current_track_progress_ms = max(min(calculated_progress, metadata.progress.track_duration), 0)
else:
current_track_progress_ms = max(calculated_progress, 0)
```
## Artwork messages
This section describes messages specific to clients with the `artwork` role, which handle display of artwork images. Artwork clients receive images in their preferred format and resolution.
**Channels:** Artwork clients can support 1-4 independent channels, allowing them to display multiple related images. For example, a device could display album artwork on one channel while simultaneously showing artist photos or background images on other channels. Each channel operates independently with its own format, resolution, and source type (album or artist artwork).
### Client → Server: `client/hello` artwork@v1 support object
The `artwork@v1_support` object in [`client/hello`](#client--server-clienthello) has this structure:
- `artwork@v1_support`: object
- `channels`: object[] - list of supported artwork channels (length 1-4), array index is the channel number
- `source`: 'album' | 'artist' | 'none' - artwork source type
- `format`: 'jpeg' | 'png' | 'bmp' - image format identifier
- `media_width`: integer - max width in pixels
- `media_height`: integer - max height in pixels
**Note:** The server will scale images to fit within the specified dimensions while preserving aspect ratio. Clients can support 1-4 independent artwork channels depending on their display capabilities. The channel number is determined by array position: `channels[0]` is channel 0 (binary message type 8), `channels[1]` is channel 1 (binary message type 9), etc.
**None source:** If a channel has `source` set to `none`, the server will not send any artwork data for that channel. This allows clients to disable and enable specific channels on the fly through [`stream/request-format`](#client--server-streamrequest-format-artwork-object) without needing to re-establish the WebSocket connection (useful for dynamic display layouts).
**Note:** Servers must support all image formats: 'jpeg', 'png', and 'bmp'.
### Client → Server: `stream/request-format` artwork object
The `artwork` object in [`stream/request-format`](#client--server-streamrequest-format) has this structure:
Request the server to change the artwork format for a specific channel. The client can send multiple `stream/request-format` messages to change formats on different channels.
After receiving this message, the server responds with [`stream/start`](#server--client-streamstart) for the artwork role with the new format, followed by immediate artwork updates through binary messages.
- `artwork`: object
- `channel`: integer - channel number (0-3) corresponding to the channel index declared in the artwork [`client/hello`](#client--server-clienthello-artworkv1-support-object)
- `source?`: 'album' | 'artist' | 'none' - artwork source type
- `format?`: 'jpeg' | 'png' | 'bmp' - requested image format identifier
- `media_width?`: integer - requested max width in pixels
- `media_height?`: integer - requested max height in pixels
### Server → Client: `stream/start` artwork object
The `artwork` object in [`stream/start`](#server--client-streamstart) has this structure:
- `artwork`: object
- `channels`: object[] - configuration for each active artwork channel, array index is the channel number
- `source`: 'album' | 'artist' | 'none' - artwork source type
- `format`: 'jpeg' | 'png' | 'bmp' - format of the encoded image
- `width`: integer - width in pixels of the encoded image
- `height`: integer - height in pixels of the encoded image
### Server → Client: Artwork (Binary)
Binary messages should be rejected if there is no active stream.
- Byte 0: message type `8`-`11` (uint8) - corresponds to artwork channel 0-3 respectively
- Bytes 1-8: timestamp (big-endian int64) - server clock time in microseconds when the image should be displayed by the device
- Rest of bytes: encoded image
The message type determines which artwork channel this image is for:
- Type `8`: Channel 0 (Artwork role, slot 0)
- Type `9`: Channel 1 (Artwork role, slot 1)
- Type `10`: Channel 2 (Artwork role, slot 2)
- Type `11`: Channel 3 (Artwork role, slot 3)
The timestamp indicates when this artwork should be displayed. Clients must translate this server timestamp to their local clock using the offset computed from clock synchronization.
**Clearing artwork:** To clear the currently displayed artwork on a specific channel, the server sends an empty binary message (only the message type byte and timestamp, with no image data) for that channel.
## Visualizer messages
This section describes messages specific to clients with the `visualizer` role, which create visual representations of the audio being played. Visualizer clients receive audio analysis data like FFT information that corresponds to the current audio timeline.
### Client → Server: `client/hello` visualizer@v1 support object
The `visualizer@v1_support` object in [`client/hello`](#client--server-clienthello) has this structure:
- `visualizer@v1_support`: object
- Desired FFT details (to be determined)
- `buffer_capacity`: integer - max size in bytes of visualization data messages in the buffer that are yet to be displayed
### Server → Client: `stream/start` visualizer object
The `visualizer` object in [`stream/start`](#server--client-streamstart) has this structure:
- `visualizer`: object
- FFT details (to be determined)
### Server → Client: `stream/clear` visualizer
When [`stream/clear`](#server--client-streamclear) includes the visualizer role, clients should clear all buffered visualization data and continue with data received after this message.
### Server → Client: Visualization Data (Binary)
Binary messages should be rejected if there is no active stream.
- Byte 0: message type `16` (uint8)
- Bytes 1-8: timestamp (big-endian int64) - server clock time in microseconds when the visualization should be displayed by the device
- Rest of bytes: visualization data
The timestamp indicates when this visualization data should be displayed, corresponding to the audio timeline. Clients must translate this server timestamp to their local clock using the offset computed from clock synchronization.
## Color messages
This section describes messages specific to clients with the `color` role, which receive colors derived from the current audio. Colors may be extracted from album artwork, provided by the music source, or manually programmed by the server.
### Server → Client: `server/state` color object
The `color` object in [`server/state`](#server--client-serverstate) has this structure:
- `color`: object
- `timestamp`: integer - server clock time in microseconds for when these colors are valid
- `background_dark?`: integer[] | null - background color suitable for dark mode as `[R, G, B]` with values 0-255. The server must ensure a minimum WCAG contrast ratio of 4.5:1 with white text and with `on_dark` (if also present).
- `background_light?`: integer[] | null - background color suitable for light mode as `[R, G, B]` with values 0-255. The server must ensure a minimum WCAG contrast ratio of 4.5:1 with black text and with `on_light` (if also present).
- `primary?`: integer[] | null - the dominant color, as `[R, G, B]` with values 0-255. Not adjusted for contrast.
- `accent?`: integer[] | null - a secondary or complementary color, as `[R, G, B]` with values 0-255. Not adjusted for contrast.
- `on_dark?`: integer[] | null - a light color suitable for use on dark backgrounds, as `[R, G, B]` with values 0-255
- `on_light?`: integer[] | null - a dark color suitable for use on light backgrounds, as `[R, G, B]` with values 0-255

118
cmd/client/main.go Normal file
View File

@@ -0,0 +1,118 @@
package main
import (
"flag"
"fmt"
"log"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"github.com/Sendspin/sendspin-go/pkg/sendspin"
"rpi-sendspin/internal/pipewire"
)
func main() {
server := flag.String("server", "localhost:8927", "server address")
name := flag.String("name", hostname(), "player name")
device := flag.String("device", "", "PipeWire sink node name (default: system default)")
listDevices := flag.Bool("list-devices", false, "list available PipeWire sinks and exit")
volume := flag.Int("volume", 80, "initial volume (0-100)")
flag.Parse()
if *listDevices {
sinks, err := pipewire.ListSinks()
if err != nil {
log.Fatalf("list sinks: %v", err)
}
if len(sinks) == 0 {
fmt.Println("no PipeWire sinks found")
return
}
fmt.Println("Available PipeWire sinks:")
for _, s := range sinks {
fmt.Printf(" %s\n", s)
}
return
}
idFile := clientIDFile()
savedID, _ := os.ReadFile(idFile)
clientID, err := sendspin.ResolveClientID("", string(savedID), func(id string) error {
if err := os.MkdirAll(filepath.Dir(idFile), 0700); err != nil {
return err
}
return os.WriteFile(idFile, []byte(id), 0600)
})
if err != nil {
log.Fatalf("resolve client ID: %v", err)
}
output := pipewire.NewOutput(*device, *name)
player, err := sendspin.NewPlayer(sendspin.PlayerConfig{
ServerAddr: *server,
PlayerName: *name,
ClientID: clientID,
Volume: *volume,
Output: output,
Reconnect: sendspin.ReconnectConfig{
Enabled: true,
InitialDelay: 2 * time.Second,
MaxDelay: 30 * time.Second,
Multiplier: 2.0,
},
OnMetadata: func(meta sendspin.Metadata) {
if meta.Artist != "" || meta.Title != "" {
log.Printf("now playing: %s — %s", meta.Artist, meta.Title)
}
},
OnError: func(err error) {
log.Printf("error: %v", err)
},
})
if err != nil {
log.Fatalf("create player: %v", err)
}
defer player.Close()
if *device != "" {
log.Printf("output: %s", *device)
} else {
log.Printf("output: PipeWire default sink")
}
log.Printf("connecting to %s as %q...", *server, *name)
if err := player.Connect(); err != nil {
log.Fatalf("connect: %v", err)
}
if err := player.Play(); err != nil {
log.Fatalf("play: %v", err)
}
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
<-sig
log.Println("stopping")
player.Stop()
}
func hostname() string {
h, _ := os.Hostname()
if h == "" {
return "sendspin-player"
}
return h
}
func clientIDFile() string {
dir, err := os.UserConfigDir()
if err != nil {
dir = os.TempDir()
}
return filepath.Join(dir, "rpi-sendspin", "client_id")
}

200
cmd/live-server/main.go Normal file
View File

@@ -0,0 +1,200 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"sort"
"strconv"
"syscall"
"time"
"github.com/Sendspin/sendspin-go/pkg/sendspin"
"rpi-sendspin/internal/live"
)
// flagSpec describes one user-facing option. The flag is registered
// under all names; help output (custom Usage below) groups them onto a
// single line so `-s, --source` reads as one option rather than two.
type flagSpec struct {
long string
short string // optional
usage string
}
var specs []flagSpec
func addStr(target *string, def, long, short, usage string) {
flag.StringVar(target, long, def, usage)
if short != "" {
flag.StringVar(target, short, def, usage)
}
specs = append(specs, flagSpec{long, short, usage})
}
func addInt(target *int, def int, long, short, usage string) {
flag.IntVar(target, long, def, usage)
if short != "" {
flag.IntVar(target, short, def, usage)
}
specs = append(specs, flagSpec{long, short, usage})
}
func main() {
var (
port int
name string
httpPort int
configPath string
source string
mqttURL string
mqttUser string
mqttPass string
mqttBase string
mqttDiscovery string
rate int
channels int
)
addInt(&port, 8927, "port", "p", "Sendspin WebSocket port")
addStr(&name, "Live Audio", "name", "n", "advertised server name")
addInt(&httpPort, 8080, "http-port", "", "HTTP/API port")
addStr(&configPath, defaultConfigPath(), "config", "c", "presets file path")
addStr(&source, "", "source", "s", "PipeWire source node (e.g. alsa_input.usb-...pro-input-0.monitor); when set, overrides preset.source")
addStr(&mqttURL, "", "mqtt-url", "", "MQTT broker URL (e.g. tcp://broker.local:1883); empty disables MQTT")
addStr(&mqttUser, "", "mqtt-username", "", "MQTT username")
addStr(&mqttPass, "", "mqtt-password", "", "MQTT password")
addStr(&mqttBase, "sendspin/live", "mqtt-base", "", "MQTT base topic")
addStr(&mqttDiscovery, "homeassistant", "mqtt-discovery", "", "Home Assistant discovery prefix")
addInt(&rate, 48000, "rate", "r", "capture sample rate")
addInt(&channels, 2, "channels", "", "capture channels")
flag.Usage = usage
flag.Parse()
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
store, err := live.NewStore(configPath)
if err != nil {
log.Fatalf("load presets: %v", err)
}
src := live.NewSource(rate, channels)
roster := live.NewRoster()
var mgr *live.Manager
server, err := sendspin.NewServer(sendspin.ServerConfig{
Port: port,
Name: name,
Source: src,
EnableMDNS: true,
DiscoverClients: true,
ClientFilter: func(id string) bool {
if mgr == nil {
return false
}
return mgr.ClientAllowed(id)
},
OnClientHello: roster.Record,
})
if err != nil {
log.Fatalf("create sendspin server: %v", err)
}
mgr = live.NewManager(store, server, src)
mgr.SetSourceOverride(source)
if source != "" {
log.Printf("source override: %s (preset.source ignored)", source)
}
disc := live.NewDiscovery()
disc.Start()
defer disc.Stop()
httpSrv := &http.Server{
Addr: ":" + strconv.Itoa(httpPort),
Handler: live.NewHTTPServer(mgr, store, disc, roster, server).Handler(),
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
}
go func() {
log.Printf("HTTP API listening on %s", httpSrv.Addr)
if err := httpSrv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("http: %v", err)
}
}()
var bridge *live.MQTTBridge
if mqttURL != "" {
bridge = live.NewMQTTBridge(live.MQTTConfig{
URL: mqttURL,
Username: mqttUser,
Password: mqttPass,
BaseTopic: mqttBase,
DiscoveryPrefix: mqttDiscovery,
}, mgr)
if err := bridge.Start(); err != nil {
log.Fatalf("mqtt: %v", err)
}
}
serverDone := make(chan error, 1)
go func() { serverDone <- server.Start() }()
log.Printf("Sendspin server %q on :%d", name, port)
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
select {
case <-sig:
log.Printf("signal received, shutting down")
case err := <-serverDone:
log.Printf("sendspin server exited: %v", err)
}
if bridge != nil {
bridge.Stop()
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = httpSrv.Shutdown(ctx)
server.Stop()
_ = src.Close()
}
func defaultConfigPath() string {
home, err := os.UserHomeDir()
if err != nil {
return "presets.json"
}
return filepath.Join(home, ".config", "sendspin-live", "presets.json")
}
// usage renders one line per logical option, pairing the short alias
// with the long form (e.g. "-s, --source"). The default flag.Usage
// would list each registration separately and double-print everything.
func usage() {
out := flag.CommandLine.Output()
fmt.Fprintf(out, "Usage: %s [flags]\n\nFlags:\n", os.Args[0])
sort.Slice(specs, func(i, j int) bool { return specs[i].long < specs[j].long })
for _, s := range specs {
f := flag.Lookup(s.long)
if f == nil {
continue
}
head := " --" + s.long
if s.short != "" {
head = " -" + s.short + ", --" + s.long
}
if f.DefValue != "" {
head += "=" + f.DefValue
}
fmt.Fprintf(out, "%-44s %s\n", head, s.usage)
}
}

88
cmd/server/main.go Normal file
View File

@@ -0,0 +1,88 @@
package main
import (
"flag"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"github.com/Sendspin/sendspin-go/pkg/sendspin"
"rpi-sendspin/internal/pipewire"
)
func main() {
port := flag.Int("port", 8927, "listen port")
name := flag.String("name", "Sendspin Server", "server name")
source := flag.String("source", "", "PipeWire source node name")
listSources := flag.Bool("list-sources", false, "list available PipeWire sources and exit")
rate := flag.Int("rate", 48000, "capture sample rate")
channels := flag.Int("channels", 2, "capture channels")
flag.Parse()
if *listSources {
printSources()
return
}
if *source == "" {
sources, err := pipewire.ListSources()
if err != nil {
log.Fatalf("list sources: %v", err)
}
if len(sources) == 0 {
log.Fatal("no PipeWire sources found; is PipeWire running?")
}
fmt.Println("Available PipeWire sources:")
for i, s := range sources {
fmt.Printf(" %d. %s\n", i+1, s)
}
fmt.Printf("\nSpecify a source with --source <node-name>\n")
fmt.Printf("Example: --source %s\n", sources[0].Name)
os.Exit(1)
}
src := pipewire.NewSource(*source, *rate, *channels)
log.Printf("capturing from %q at %d Hz %d ch", *source, *rate, *channels)
server, err := sendspin.NewServer(sendspin.ServerConfig{
Port: *port,
Name: *name,
Source: src,
})
if err != nil {
log.Fatalf("create server: %v", err)
}
if err := server.Start(); err != nil {
log.Fatalf("start server: %v", err)
}
log.Printf("server %q listening on :%d", *name, *port)
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
<-sig
log.Println("stopping")
server.Stop()
src.Close()
}
func printSources() {
sources, err := pipewire.ListSources()
if err != nil {
log.Fatalf("list sources: %v", err)
}
if len(sources) == 0 {
fmt.Println("no PipeWire sources found")
return
}
fmt.Println("Available PipeWire sources:")
for _, s := range sources {
fmt.Printf(" %s\n", s)
}
}

16
dist/systemd/sendspin-client.service vendored Normal file
View File

@@ -0,0 +1,16 @@
[Unit]
Description=Sendspin audio player
After=pipewire.service network-online.target
Wants=pipewire.service
[Service]
ExecStart=%h/.local/bin/sendspin-client \
--server 192.168.1.100:8927 \
--name %H \
--device alsa_output.usb-Device-00.analog-stereo \
--volume 80
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target

17
dist/systemd/sendspin-server.service vendored Normal file
View File

@@ -0,0 +1,17 @@
[Unit]
Description=Sendspin audio streaming server
After=pipewire.service network-online.target
Wants=pipewire.service
[Service]
ExecStart=%h/.local/bin/sendspin-server \
--source alsa_input.usb-Device-00.analog-stereo \
--name %H \
--port 8927 \
--rate 48000 \
--channels 2
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target

162
esphome/speaker.yaml Normal file
View File

@@ -0,0 +1,162 @@
substitutions:
name: "audio-test"
friendly_name: "Audio Test"
# AP fallback password (8+ chars)
ap_password: "SYOMYqzKWbfl"
esphome:
name: ${name}
friendly_name: ${friendly_name}
on_boot:
priority: 600.0
then:
# PCM5102 SCK pin is tied to GND on the breakout; this GPIO is also
# soldered to that node, so we hold it LOW to avoid driving against GND.
- output.turn_off: sck_pin_low
esp32:
board: esp32-s3-devkitc-1
framework:
type: esp-idf
psram:
mode: quad
speed: 80MHz
logger:
api:
encryption:
key: !secret api_encryption_key
ota:
- platform: esphome
password: !secret ota_password
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
power_save_mode: none
ap:
ssid: "${friendly_name} Fallback"
password: ${ap_password}
captive_portal:
http_request:
timeout: 10s
verify_ssl: false
output:
- platform: gpio
pin: GPIO15
id: sck_pin_low
i2s_audio:
id: i2s_bus
i2s_lrclk_pin: GPIO18 # A0 -> LCK
i2s_bclk_pin: GPIO16 # A2 -> BCK
speaker:
- platform: i2s_audio
id: pcm5102_dac
i2s_audio_id: i2s_bus
i2s_dout_pin: GPIO17 # A1 -> DIN
dac_type: external
sample_rate: 48000
bits_per_sample: 16bit
channel: stereo
timeout: never
buffer_duration: 100ms
- platform: mixer
id: audio_mixer
output_speaker: pcm5102_dac
num_channels: 2
task_stack_in_psram: true
source_speakers:
- id: announcement_mixer_input
timeout: never
- id: media_mixer_input
timeout: never
- platform: resampler
id: media_resampler
output_speaker: media_mixer_input
sample_rate: 48000
bits_per_sample: 16
- platform: resampler
id: announcement_resampler
output_speaker: announcement_mixer_input
sample_rate: 48000
bits_per_sample: 16
sendspin:
id: sendspin_hub
task_stack_in_psram: false
media_source:
- platform: http_request
id: http_media_source
buffer_size: 500000
- platform: http_request
id: http_announcement_source
buffer_size: 250000
- platform: sendspin
id: sendspin_media_source
media_player:
- platform: sendspin
id: sendspin_group_media_player
- platform: speaker_source
id: external_media_player
name: "Media Player"
media_pipeline:
format: FLAC
num_channels: 2
sample_rate: 48000
speaker: media_resampler
sources:
- http_media_source
- sendspin_media_source
announcement_pipeline:
format: FLAC
num_channels: 1
sample_rate: 48000
speaker: announcement_resampler
sources:
- http_announcement_source
on_mute:
- mixer_speaker.apply_ducking:
id: media_mixer_input
decibel_reduction: 51
duration: 0.4s
- mixer_speaker.apply_ducking:
id: announcement_mixer_input
decibel_reduction: 51
duration: 0.4s
on_unmute:
- mixer_speaker.apply_ducking:
id: media_mixer_input
decibel_reduction: 0
duration: 0.4s
- mixer_speaker.apply_ducking:
id: announcement_mixer_input
decibel_reduction: 0
duration: 0.4s
external_components:
- source:
# https://github.com/esphome/esphome/pull/14933
type: git
url: https://github.com/kahrendt/esphome
ref: 7a6cf5c8472b7e2fa18ee0fc314f66a80d249e32
components: [const, media_source, sendspin]
- source:
# https://github.com/esphome/esphome/pull/12429
type: git
url: https://github.com/esphome/esphome
ref: ff8ce89556748509d7ee8724e12d9d43d3c8c1e8
refresh: 0s
components: [http_request]

BIN
file.wav Normal file

Binary file not shown.

46
go.mod Normal file
View File

@@ -0,0 +1,46 @@
module rpi-sendspin
go 1.24.1
require github.com/Sendspin/sendspin-go v1.7.0
replace github.com/Sendspin/sendspin-go => ./third_party/sendspin-go
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/eclipse/paho.mqtt.golang v1.5.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/gen2brain/malgo v0.11.24 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/hajimehoshi/go-mp3 v0.3.4 // indirect
github.com/hashicorp/mdns v1.0.6 // indirect
github.com/icza/bitio v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mewkiz/flac v1.0.13 // indirect
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d // indirect
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 // indirect
github.com/miekg/dns v1.1.55 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/mod v0.27.0 // indirect
golang.org/x/net v0.44.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.29.0 // indirect
golang.org/x/tools v0.36.0 // indirect
gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

164
go.sum Normal file
View File

@@ -0,0 +1,164 @@
github.com/Sendspin/sendspin-go v1.7.0 h1:fd91KXj3TRINUp0YONCljUnNbTr4xIlTz6RXzv4aQH8=
github.com/Sendspin/sendspin-go v1.7.0/go.mod h1:h+MBsD/sFDZ/BdUrSvs07WRD79VluRDzvOl5RymlXoA=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/gen2brain/malgo v0.11.24 h1:hHcIJVfzWcEDHFdPl5Dl/CUSOjzOleY0zzAV8Kx+imE=
github.com/gen2brain/malgo v0.11.24/go.mod h1:f9TtuN7DVrXMiV/yIceMeWpvanyVzJQMlBecJFVMxww=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68=
github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo=
github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo=
github.com/hashicorp/mdns v1.0.6 h1:SV8UcjnQ/+C7KeJ/QeVD/mdN2EmzYfcGfufcuzxfCLQ=
github.com/hashicorp/mdns v1.0.6/go.mod h1:X4+yWh+upFECLOki1doUPaKpgNQII9gy4bUdCYKNhmM=
github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0=
github.com/icza/bitio v1.1.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A=
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k=
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mewkiz/flac v1.0.13 h1:6wF8rRQKBFW159Daqx6Ro7K5ZnlVhHUKfS5aTsC4oXs=
github.com/mewkiz/flac v1.0.13/go.mod h1:HfPYDA+oxjyuqMu2V+cyKcxF51KM6incpw5eZXmfA6k=
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d h1:IL2tii4jXLdhCeQN69HNzYYW1kl0meSG0wt5+sLwszU=
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d/go.mod h1:SIpumAnUWSy0q9RzKD3pyH3g1t5vdawUAPcW5tQrUtI=
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 h1:h8O1byDZ1uk6RUXMhj1QJU3VXFKXHDZxr4TXRPGeBa8=
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985/go.mod h1:uiPmbdUbdt1NkGApKl7htQjZ8S7XaGUAVulJUJ9v6q4=
github.com/miekg/dns v1.1.55 h1:GoQ4hpsj0nFLYe+bWiCToyrBEJXkQfOOIvFGFy0lEgo=
github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302 h1:xeVptzkP8BuJhoIjNizd2bRHfq9KB9HfOLZu90T04XM=
gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302/go.mod h1:/L5E7a21VWl8DeuCPKxQBdVG5cy+L0MRZ08B1wnqt7g=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

1924
home-assistant-voice.yaml Normal file

File diff suppressed because it is too large Load Diff

9
internal/live/errors.go Normal file
View File

@@ -0,0 +1,9 @@
package live
import "errors"
var (
errNotFound = errors.New("preset not found")
errBadRequest = errors.New("invalid request")
errNoActivePreset = errors.New("no active preset")
)

293
internal/live/http.go Normal file
View File

@@ -0,0 +1,293 @@
package live
import (
"encoding/json"
"errors"
"net/http"
"time"
"github.com/Sendspin/sendspin-go/pkg/sendspin"
"rpi-sendspin/internal/pipewire"
)
type HTTPServer struct {
mgr *Manager
store *Store
disc *Discovery
roster *Roster
srv *sendspin.Server
}
func NewHTTPServer(mgr *Manager, store *Store, disc *Discovery, roster *Roster, srv *sendspin.Server) *HTTPServer {
return &HTTPServer{mgr: mgr, store: store, disc: disc, roster: roster, srv: srv}
}
func (h *HTTPServer) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/presets", h.listPresets)
mux.HandleFunc("POST /api/presets", h.createPreset)
mux.HandleFunc("GET /api/presets/{id}", h.getPreset)
mux.HandleFunc("PUT /api/presets/{id}", h.putPreset)
mux.HandleFunc("DELETE /api/presets/{id}", h.deletePreset)
mux.HandleFunc("GET /api/clients", h.listClients)
mux.HandleFunc("GET /api/sources", h.listSources)
mux.HandleFunc("GET /api/state", h.getState)
mux.HandleFunc("POST /api/active_preset", h.setActivePreset)
mux.HandleFunc("POST /api/playback", h.setPlayback)
mux.HandleFunc("POST /api/volume", h.setVolume)
mux.Handle("/", staticHandler())
return mux
}
type setActivePresetReq struct {
ID string `json:"id"`
}
func (h *HTTPServer) setActivePreset(w http.ResponseWriter, r *http.Request) {
var req setActivePresetReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpError(w, http.StatusBadRequest, "invalid JSON")
return
}
if err := h.mgr.SetActivePreset(req.ID); err != nil {
httpError(w, MapManagerError(err), err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
type setPlaybackReq struct {
Command string `json:"command"`
}
func (h *HTTPServer) setPlayback(w http.ResponseWriter, r *http.Request) {
var req setPlaybackReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpError(w, http.StatusBadRequest, "invalid JSON")
return
}
if err := h.mgr.SetPlayback(req.Command); err != nil {
httpError(w, MapManagerError(err), err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
type setVolumeReq struct {
Volume int `json:"volume"`
}
func (h *HTTPServer) setVolume(w http.ResponseWriter, r *http.Request) {
var req setVolumeReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpError(w, http.StatusBadRequest, "invalid JSON")
return
}
if err := h.mgr.SetVolume(req.Volume); err != nil {
httpError(w, MapManagerError(err), err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *HTTPServer) listPresets(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, h.store.List())
}
func (h *HTTPServer) createPreset(w http.ResponseWriter, r *http.Request) {
var p Preset
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
httpError(w, http.StatusBadRequest, "invalid JSON")
return
}
if err := h.store.Create(p); err != nil {
httpError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, p)
}
func (h *HTTPServer) getPreset(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
p, ok := h.store.Get(id)
if !ok {
httpError(w, http.StatusNotFound, "not found")
return
}
writeJSON(w, http.StatusOK, p)
}
func (h *HTTPServer) putPreset(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var p Preset
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
httpError(w, http.StatusBadRequest, "invalid JSON")
return
}
if p.ID == "" {
p.ID = id
}
if p.ID != id {
httpError(w, http.StatusBadRequest, "id mismatch")
return
}
if err := h.store.Put(p); err != nil {
httpError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, p)
}
func (h *HTTPServer) deletePreset(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
// If the deleted preset is currently active, transition to off first.
active, _, _, _ := h.mgr.State()
if active == id {
_ = h.mgr.SetActivePreset("")
}
ok, err := h.store.Delete(id)
if err != nil {
httpError(w, http.StatusInternalServerError, err.Error())
return
}
if !ok {
httpError(w, http.StatusNotFound, "not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
type clientView struct {
ClientID string `json:"client_id,omitempty"`
Instance string `json:"instance,omitempty"`
Name string `json:"name,omitempty"`
Connected bool `json:"connected"`
LastSeen time.Time `json:"last_seen,omitempty"`
// Source indicates how this entry was discovered:
// "connected" → currently in the active group (after ClientFilter accepted hello)
// "roster" → handshaked at least once (we know its client_id; not currently in group)
// "mdns" → only seen via mDNS; never handshaked, no client_id known
Source string `json:"source"`
}
func (h *HTTPServer) listClients(w http.ResponseWriter, _ *http.Request) {
// Build a unified view of every speaker we know about, keyed by
// client_id wherever possible so the UI can present a real, stable
// identifier rather than the mDNS instance name (which differs from
// client_id on most ESPHome / firmware-derived setups).
out := make([]clientView, 0)
byID := map[string]int{}
// 1) Currently-connected clients: authoritative; takes precedence.
for _, c := range h.srv.Clients() {
byID[c.ID] = len(out)
out = append(out, clientView{
ClientID: c.ID,
Name: c.Name,
Connected: true,
LastSeen: time.Now(),
Source: "connected",
})
}
// 2) Roster: every client_id we've seen via hello, even if rejected.
for _, e := range h.roster.List() {
if _, ok := byID[e.ClientID]; ok {
continue
}
byID[e.ClientID] = len(out)
out = append(out, clientView{
ClientID: e.ClientID,
Name: e.Name,
Connected: false,
LastSeen: e.LastSeen,
Source: "roster",
})
}
// 3) mDNS-only entries: never handshaked, so we don't know
// client_id. Useful for "the speaker is on the network but
// something is wrong" diagnostics; not directly usable for
// preset membership.
for _, d := range h.disc.List() {
out = append(out, clientView{
Instance: d.Instance,
Name: d.Instance,
LastSeen: d.LastSeen,
Source: "mdns",
})
}
writeJSON(w, http.StatusOK, out)
}
type sourceView struct {
Name string `json:"name"`
Description string `json:"description"`
IsMonitor bool `json:"is_monitor"`
}
func (h *HTTPServer) listSources(w http.ResponseWriter, _ *http.Request) {
targets, err := pipewire.ListCaptureTargets()
if err != nil {
httpError(w, http.StatusInternalServerError, err.Error())
return
}
out := make([]sourceView, 0, len(targets))
for _, t := range targets {
out = append(out, sourceView{
Name: t.Name,
Description: t.Description,
IsMonitor: t.IsMonitor,
})
}
writeJSON(w, http.StatusOK, out)
}
type stateView struct {
ActivePreset string `json:"active_preset"`
Playback Playback `json:"playback"`
Volume int `json:"volume"`
Members []MemberState `json:"members"`
}
func (h *HTTPServer) getState(w http.ResponseWriter, _ *http.Request) {
active, pb, vol, members := h.mgr.State()
writeJSON(w, http.StatusOK, stateView{
ActivePreset: active,
Playback: pb,
Volume: vol,
Members: members,
})
}
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
func httpError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
// MapManagerError converts manager sentinel errors to HTTP status codes.
// Currently unused by the HTTP layer (handlers use sentinel directly),
// but exported for potential use by the MQTT bridge command path.
func MapManagerError(err error) int {
switch {
case errors.Is(err, errNotFound):
return http.StatusNotFound
case errors.Is(err, errBadRequest), errors.Is(err, errNoActivePreset):
return http.StatusBadRequest
default:
return http.StatusInternalServerError
}
}

456
internal/live/manager.go Normal file
View File

@@ -0,0 +1,456 @@
package live
import (
"log"
"sync"
"github.com/Sendspin/sendspin-go/pkg/sendspin"
)
type Playback string
const (
Off Playback = "off"
Paused Playback = "paused"
Playing Playback = "playing"
)
// StateChange is published to subscribers (MQTT bridge) whenever the
// manager's externally-visible state changes.
type StateChange struct {
ActivePreset string
Playback Playback
Volume int
Presets []Preset
}
// MemberState is per-speaker detail used by /api/state.
type MemberState struct {
ClientID string `json:"client_id"`
Connected bool `json:"connected"`
Muted bool `json:"muted"`
Volume int `json:"volume"`
}
// Manager is the single source of truth for the live-audio server. All
// state transitions are serialized on its internal mutex.
type Manager struct {
store *Store
server *sendspin.Server
src *Source
mu sync.Mutex
active string // preset id, "" when off
playback Playback
sourceOverride string // when non-empty, wins over preset.Source
subscribers []chan StateChange
subscribersMu sync.Mutex
}
func NewManager(store *Store, server *sendspin.Server, src *Source) *Manager {
m := &Manager{
store: store,
server: server,
src: src,
playback: Off,
}
// React to preset edits: if the active preset's content changed (or
// it was deleted), reapply current state.
store.OnWrite(m.reapply)
return m
}
// SetSourceOverride pins the captured PipeWire node, ignoring whatever
// `source` field the active preset carries. Passing "" reverts to
// preset-driven behavior. Safe to call before or after activation; the
// new value takes effect on the next preset transition (or immediately
// if a preset is already active).
func (m *Manager) SetSourceOverride(node string) {
m.mu.Lock()
m.sourceOverride = node
active := m.active
m.mu.Unlock()
if active == "" {
return
}
// Reapply so the running pw-cat picks up the new target.
m.reapply()
}
// resolveSource returns the PipeWire node to capture for the given
// preset, honoring sourceOverride when set. Caller must hold m.mu.
func (m *Manager) resolveSource(p Preset) string {
if m.sourceOverride != "" {
return m.sourceOverride
}
return p.Source
}
// ClientAllowed is intended to be plugged into ServerConfig.ClientFilter.
// It admits a client only if it belongs to the currently active preset.
func (m *Manager) ClientAllowed(clientID string) bool {
m.mu.Lock()
defer m.mu.Unlock()
if m.active == "" {
return false
}
p, ok := m.store.Get(m.active)
if !ok {
return false
}
for _, id := range p.Members {
if id == clientID {
return true
}
}
return false
}
// Subscribe returns a channel that receives a StateChange whenever the
// externally-visible state changes. The channel is buffered; slow
// subscribers drop events.
func (m *Manager) Subscribe() <-chan StateChange {
ch := make(chan StateChange, 8)
m.subscribersMu.Lock()
m.subscribers = append(m.subscribers, ch)
m.subscribersMu.Unlock()
return ch
}
func (m *Manager) publish() {
m.subscribersMu.Lock()
subs := append([]chan StateChange(nil), m.subscribers...)
m.subscribersMu.Unlock()
state := StateChange{
ActivePreset: m.active,
Playback: m.playback,
Volume: m.groupVolumeLocked(),
Presets: m.store.List(),
}
for _, ch := range subs {
select {
case ch <- state:
default:
}
}
}
// SetActivePreset transitions to the given preset id ("" → off). Members
// of the new preset are claimed; members no longer needed are released.
// Carries over playback state where possible.
func (m *Manager) SetActivePreset(id string) error {
m.mu.Lock()
defer m.mu.Unlock()
if id == m.active {
return nil
}
var nextMembers map[string]struct{}
var nextSource string
if id != "" {
p, ok := m.store.Get(id)
if !ok {
return errNotFound
}
nextMembers = make(map[string]struct{}, len(p.Members))
for _, id := range p.Members {
nextMembers[id] = struct{}{}
}
nextSource = m.resolveSource(p)
}
// Drop currently-connected clients that don't belong to the new preset.
for _, c := range m.server.Clients() {
if _, ok := nextMembers[c.ID]; !ok {
m.server.DisconnectClient(c.ID)
}
}
prev := m.active
m.active = id
if id == "" {
// off
if m.playback == Playing {
m.server.NotifyStreamEndAll()
}
m.playback = Off
m.src.SetActive(false)
m.src.SetTarget("")
log.Printf("live: %s → off", prev)
m.publish()
return nil
}
// On entry from off, default to paused; otherwise carry over.
if prev == "" {
m.playback = Paused
}
m.src.SetTarget(nextSource)
if m.playback == Playing {
m.src.SetActive(true)
m.server.NotifyStreamStartAll()
} else {
m.src.SetActive(false)
}
log.Printf("live: active preset %q (%s)", id, m.playback)
m.publish()
return nil
}
// SetPlayback applies a play/pause/stop command. Stop sets the active
// preset to "".
func (m *Manager) SetPlayback(cmd string) error {
switch cmd {
case "stop":
return m.SetActivePreset("")
case "play":
return m.setPlayback(Playing)
case "pause":
return m.setPlayback(Paused)
default:
return errBadRequest
}
}
func (m *Manager) setPlayback(next Playback) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.active == "" {
return errNoActivePreset
}
if m.playback == next {
return nil
}
prev := m.playback
m.playback = next
switch next {
case Playing:
m.src.SetActive(true)
// If transitioning from paused, clients are already connected
// but received stream/end. Resend stream/start.
if prev == Paused {
m.server.NotifyStreamStartAll()
}
case Paused:
m.src.SetActive(false)
if prev == Playing {
m.server.NotifyStreamEndAll()
}
}
log.Printf("live: %s → %s", prev, next)
m.publish()
return nil
}
// SetVolume applies the spec's group-volume algorithm to the currently
// connected player members. No-op when off.
func (m *Manager) SetVolume(target int) error {
if target < 0 {
target = 0
}
if target > 100 {
target = 100
}
m.mu.Lock()
defer m.mu.Unlock()
if m.active == "" {
return nil
}
clients := m.server.Clients()
players := make([]sendspin.ClientInfo, 0, len(clients))
for _, c := range clients {
players = append(players, c)
}
if len(players) == 0 {
return nil
}
// Per spec ("Setting group volume", §Controller messages): apply
// the *full* delta to every player, clamp out-of-range values, then
// redistribute the lost delta across the still-eligible players.
// Iterate until nothing clamps or every player is at a boundary.
proposed := make(map[string]int, len(players))
for _, c := range players {
proposed[c.ID] = c.Volume
}
sum := 0
for _, c := range players {
sum += proposed[c.ID]
}
delta := target - sum/len(players)
for _, c := range players {
proposed[c.ID] += delta
}
clamped := make(map[string]bool, len(players))
for range 10 {
lost := 0
any := false
for _, c := range players {
v := proposed[c.ID]
if v < 0 {
lost += v // negative
proposed[c.ID] = 0
if !clamped[c.ID] {
any = true
clamped[c.ID] = true
}
} else if v > 100 {
lost += v - 100 // positive
proposed[c.ID] = 100
if !clamped[c.ID] {
any = true
clamped[c.ID] = true
}
}
}
if !any || lost == 0 {
break
}
eligible := make([]string, 0, len(players))
for _, c := range players {
if !clamped[c.ID] {
eligible = append(eligible, c.ID)
}
}
if len(eligible) == 0 {
break
}
share := lost / len(eligible)
rem := lost - share*len(eligible)
for _, id := range eligible {
d := share
if rem > 0 {
d++
rem--
} else if rem < 0 {
d--
rem++
}
proposed[id] += d
}
}
for _, c := range m.serverClientsByID(players) {
v := proposed[c.ID()]
_ = c.Send("server/command", map[string]any{
"player": map[string]any{
"command": "volume",
"volume": v,
},
})
}
m.publish()
return nil
}
// reapply is invoked by the preset store after a write so the manager
// can re-evaluate which speakers should be connected and what source to
// capture (e.g. the active preset's member list was edited, or it was
// deleted).
func (m *Manager) reapply() {
m.mu.Lock()
active := m.active
m.mu.Unlock()
if active == "" {
return
}
p, ok := m.store.Get(active)
if !ok {
_ = m.SetActivePreset("")
return
}
m.mu.Lock()
defer m.mu.Unlock()
allowed := make(map[string]struct{}, len(p.Members))
for _, id := range p.Members {
allowed[id] = struct{}{}
}
for _, c := range m.server.Clients() {
if _, ok := allowed[c.ID]; !ok {
m.server.DisconnectClient(c.ID)
}
}
m.src.SetTarget(m.resolveSource(p))
m.publish()
}
// State returns a read-only snapshot for the HTTP /api/state endpoint.
func (m *Manager) State() (string, Playback, int, []MemberState) {
m.mu.Lock()
defer m.mu.Unlock()
members := make([]MemberState, 0)
allowed := map[string]struct{}{}
if p, ok := m.store.Get(m.active); ok {
for _, id := range p.Members {
allowed[id] = struct{}{}
members = append(members, MemberState{ClientID: id})
}
}
for _, c := range m.server.Clients() {
if _, ok := allowed[c.ID]; !ok {
continue
}
for i, mem := range members {
if mem.ClientID == c.ID {
members[i].Connected = true
members[i].Muted = c.Muted
members[i].Volume = c.Volume
}
}
}
return m.active, m.playback, m.groupVolumeLocked(), members
}
func (m *Manager) groupVolumeLocked() int {
if m.active == "" {
return 0
}
clients := m.server.Clients()
if len(clients) == 0 {
return 0
}
sum := 0
for _, c := range clients {
sum += c.Volume
}
return sum / len(clients)
}
// serverClientsByID returns the live *ServerClient instances matching
// the IDs in the snapshot. Used so SetVolume can call .Send() rather
// than going through the snapshot, which is value-typed.
func (m *Manager) serverClientsByID(snapshot []sendspin.ClientInfo) []*sendspin.ServerClient {
if m.server.Group() == nil {
return nil
}
want := make(map[string]struct{}, len(snapshot))
for _, c := range snapshot {
want[c.ID] = struct{}{}
}
out := make([]*sendspin.ServerClient, 0, len(snapshot))
for _, c := range m.server.Group().Clients() {
if _, ok := want[c.ID()]; ok {
out = append(out, c)
}
}
return out
}

164
internal/live/mdns.go Normal file
View File

@@ -0,0 +1,164 @@
package live
import (
"context"
"io"
"log"
"sort"
"strings"
"sync"
"time"
"github.com/hashicorp/mdns"
)
// DiscoveredClient is a snapshot of one mDNS advertisement for a
// Sendspin client (`_sendspin._tcp`). It does not include client_id —
// that field is only known after WebSocket handshake. The HTTP layer
// merges this snapshot with sendspin.Server.Clients() to produce the
// full /api/clients view.
type DiscoveredClient struct {
Instance string `json:"instance"`
Host string `json:"host"`
Port int `json:"port"`
LastSeen time.Time `json:"last_seen"`
}
// Discovery continuously browses for _sendspin._tcp services. The list
// of currently-known clients is exposed via List(). Entries persist for
// `ttl` after their most recent sighting, then are pruned.
type Discovery struct {
ttl time.Duration
mu sync.RWMutex
clients map[string]DiscoveredClient
ctx context.Context
cancel context.CancelFunc
}
func NewDiscovery() *Discovery {
ctx, cancel := context.WithCancel(context.Background())
return &Discovery{
ttl: 2 * time.Minute,
clients: make(map[string]DiscoveredClient),
ctx: ctx,
cancel: cancel,
}
}
func (d *Discovery) Start() {
go d.browseLoop()
go d.pruneLoop()
}
func (d *Discovery) Stop() {
d.cancel()
}
func (d *Discovery) List() []DiscoveredClient {
d.mu.RLock()
defer d.mu.RUnlock()
out := make([]DiscoveredClient, 0, len(d.clients))
for _, c := range d.clients {
out = append(out, c)
}
sort.Slice(out, func(i, j int) bool { return out[i].Instance < out[j].Instance })
return out
}
// sendspinService matches entries advertised under `_sendspin._tcp` —
// other devices on the LAN (WLED, Meshtastic, AirPlay, …) hit the same
// multicast channel and hashicorp/mdns happily writes them to our
// entries chan regardless of what we queried for. Filter by service
// type before recording.
const sendspinService = "_sendspin._tcp"
func (d *Discovery) record(entry *mdns.ServiceEntry) {
if !strings.Contains(entry.Name, "."+sendspinService) {
return
}
host := ""
if entry.AddrV4 != nil {
host = entry.AddrV4.String()
} else if entry.AddrV6 != nil {
host = entry.AddrV6.String()
}
if host == "" {
return
}
instance := entry.Name
if i := strings.Index(instance, "."); i > 0 {
instance = instance[:i]
}
d.mu.Lock()
d.clients[instance] = DiscoveredClient{
Instance: instance,
Host: host,
Port: entry.Port,
LastSeen: time.Now(),
}
d.mu.Unlock()
}
var silentLogger = log.New(io.Discard, "", 0)
func (d *Discovery) browseLoop() {
for {
select {
case <-d.ctx.Done():
return
default:
}
entries := make(chan *mdns.ServiceEntry, 32)
done := make(chan struct{})
go func() {
defer close(done)
for entry := range entries {
d.record(entry)
}
}()
params := &mdns.QueryParam{
Service: "_sendspin._tcp",
Domain: "local",
Timeout: 5 * time.Second,
Entries: entries,
Logger: silentLogger,
}
if err := mdns.Query(params); err != nil {
log.Printf("mdns query: %v", err)
}
close(entries)
<-done
select {
case <-d.ctx.Done():
return
case <-time.After(10 * time.Second):
}
}
}
func (d *Discovery) pruneLoop() {
t := time.NewTicker(30 * time.Second)
defer t.Stop()
for {
select {
case <-d.ctx.Done():
return
case <-t.C:
cutoff := time.Now().Add(-d.ttl)
d.mu.Lock()
for k, v := range d.clients {
if v.LastSeen.Before(cutoff) {
delete(d.clients, k)
}
}
d.mu.Unlock()
}
}
}

241
internal/live/mqtt.go Normal file
View File

@@ -0,0 +1,241 @@
package live
import (
"encoding/json"
"fmt"
"log"
"strconv"
"strings"
"time"
mqtt "github.com/eclipse/paho.mqtt.golang"
)
// MQTTConfig holds the bridge configuration loaded from CLI flags / env.
type MQTTConfig struct {
URL string
Username string
Password string
BaseTopic string // e.g., "sendspin/live"
DiscoveryPrefix string // e.g., "homeassistant"
InstanceID string // used in HA discovery object_ids; defaults from BaseTopic
}
type MQTTBridge struct {
cfg MQTTConfig
mgr *Manager
client mqtt.Client
}
func NewMQTTBridge(cfg MQTTConfig, mgr *Manager) *MQTTBridge {
if cfg.BaseTopic == "" {
cfg.BaseTopic = "sendspin/live"
}
if cfg.DiscoveryPrefix == "" {
cfg.DiscoveryPrefix = "homeassistant"
}
if cfg.InstanceID == "" {
cfg.InstanceID = strings.ReplaceAll(cfg.BaseTopic, "/", "_")
}
return &MQTTBridge{cfg: cfg, mgr: mgr}
}
func (b *MQTTBridge) Start() error {
opts := mqtt.NewClientOptions().
AddBroker(b.cfg.URL).
SetClientID("sendspin-live-" + b.cfg.InstanceID).
SetUsername(b.cfg.Username).
SetPassword(b.cfg.Password).
SetAutoReconnect(true).
SetCleanSession(true).
SetKeepAlive(30 * time.Second).
SetWill(b.topic("availability"), "offline", 1, true).
SetOnConnectHandler(b.onConnect).
SetConnectionLostHandler(func(_ mqtt.Client, err error) {
log.Printf("mqtt connection lost: %v", err)
})
b.client = mqtt.NewClient(opts)
tok := b.client.Connect()
tok.Wait()
if err := tok.Error(); err != nil {
return fmt.Errorf("mqtt connect: %w", err)
}
// React to manager state changes.
go b.publishLoop(b.mgr.Subscribe())
return nil
}
func (b *MQTTBridge) Stop() {
if b.client != nil && b.client.IsConnected() {
_ = b.publish("availability", "offline", true)
b.client.Disconnect(250)
}
}
func (b *MQTTBridge) topic(suffix string) string {
return b.cfg.BaseTopic + "/" + suffix
}
func (b *MQTTBridge) publish(suffix, payload string, retain bool) error {
t := b.client.Publish(b.topic(suffix), 1, retain, payload)
t.Wait()
return t.Error()
}
func (b *MQTTBridge) onConnect(_ mqtt.Client) {
log.Printf("mqtt connected")
_ = b.publish("availability", "online", true)
b.publishDiscovery()
// Snapshot the current state and publish.
active, pb, vol, _ := b.mgr.State()
b.publishState(StateChange{
ActivePreset: active,
Playback: pb,
Volume: vol,
Presets: b.mgr.store.List(),
})
// Subscribe command topics.
b.client.Subscribe(b.topic("active_preset/set"), 1, b.handleActivePresetSet)
b.client.Subscribe(b.topic("playback/set"), 1, b.handlePlaybackSet)
b.client.Subscribe(b.topic("volume/set"), 1, b.handleVolumeSet)
}
func (b *MQTTBridge) handleActivePresetSet(_ mqtt.Client, msg mqtt.Message) {
id := strings.TrimSpace(string(msg.Payload()))
if err := b.mgr.SetActivePreset(id); err != nil {
log.Printf("mqtt active_preset/set %q: %v", id, err)
}
}
func (b *MQTTBridge) handlePlaybackSet(_ mqtt.Client, msg mqtt.Message) {
cmd := strings.TrimSpace(string(msg.Payload()))
if err := b.mgr.SetPlayback(cmd); err != nil {
log.Printf("mqtt playback/set %q: %v", cmd, err)
}
}
func (b *MQTTBridge) handleVolumeSet(_ mqtt.Client, msg mqtt.Message) {
v, err := strconv.Atoi(strings.TrimSpace(string(msg.Payload())))
if err != nil {
log.Printf("mqtt volume/set bad payload: %v", err)
return
}
if err := b.mgr.SetVolume(v); err != nil {
log.Printf("mqtt volume/set: %v", err)
}
}
func (b *MQTTBridge) publishLoop(events <-chan StateChange) {
for ev := range events {
b.publishState(ev)
}
}
func (b *MQTTBridge) publishState(ev StateChange) {
_ = b.publish("active_preset", ev.ActivePreset, true)
_ = b.publish("playback", string(ev.Playback), true)
_ = b.publish("volume", strconv.Itoa(ev.Volume), true)
type p struct {
ID string `json:"id"`
Name string `json:"name"`
}
out := make([]p, 0, len(ev.Presets))
for _, pr := range ev.Presets {
out = append(out, p{ID: pr.ID, Name: pr.Name})
}
data, _ := json.Marshal(out)
_ = b.publish("presets", string(data), true)
// HA discovery for the preset select depends on the list of preset
// IDs, so republish the select config whenever presets change.
b.publishSelectConfig(ev.Presets)
}
// publishDiscovery emits HA MQTT discovery payloads for the entities
// this bridge exposes. Retained + idempotent so repeating on every
// connect is harmless.
func (b *MQTTBridge) publishDiscovery() {
b.publishSelectConfig(b.mgr.store.List())
// Playback select.
b.publishConfig("select", "playback", map[string]any{
"name": "Sendspin Live Playback",
"unique_id": b.cfg.InstanceID + "_playback",
"command_topic": b.topic("playback/set"),
"state_topic": b.topic("playback"),
"options": []string{"playing", "paused", "off"},
"availability_topic": b.topic("availability"),
"device": b.deviceBlock(),
})
// Volume number.
b.publishConfig("number", "volume", map[string]any{
"name": "Sendspin Live Volume",
"unique_id": b.cfg.InstanceID + "_volume",
"command_topic": b.topic("volume/set"),
"state_topic": b.topic("volume"),
"min": 0,
"max": 100,
"step": 1,
"mode": "slider",
"availability_topic": b.topic("availability"),
"device": b.deviceBlock(),
})
// Availability binary_sensor.
b.publishConfig("binary_sensor", "availability", map[string]any{
"name": "Sendspin Live Available",
"unique_id": b.cfg.InstanceID + "_availability",
"state_topic": b.topic("availability"),
"payload_on": "online",
"payload_off": "offline",
"device_class": "connectivity",
"device": b.deviceBlock(),
})
}
func (b *MQTTBridge) publishSelectConfig(presets []Preset) {
options := make([]string, 0, len(presets)+1)
options = append(options, "")
for _, p := range presets {
options = append(options, p.ID)
}
b.publishConfig("select", "active_preset", map[string]any{
"name": "Sendspin Live Preset",
"unique_id": b.cfg.InstanceID + "_active_preset",
"command_topic": b.topic("active_preset/set"),
"state_topic": b.topic("active_preset"),
"options": options,
"availability_topic": b.topic("availability"),
"device": b.deviceBlock(),
})
}
func (b *MQTTBridge) publishConfig(component, objectID string, payload map[string]any) {
topic := fmt.Sprintf("%s/%s/%s/%s/config",
b.cfg.DiscoveryPrefix, component, b.cfg.InstanceID, objectID)
data, err := json.Marshal(payload)
if err != nil {
log.Printf("mqtt discovery marshal: %v", err)
return
}
t := b.client.Publish(topic, 1, true, data)
t.Wait()
if err := t.Error(); err != nil {
log.Printf("mqtt publish %s: %v", topic, err)
}
}
func (b *MQTTBridge) deviceBlock() map[string]any {
return map[string]any{
"identifiers": []string{b.cfg.InstanceID},
"name": "Sendspin Live",
"manufacturer": "sendspin",
"model": "live-server",
}
}

168
internal/live/preset.go Normal file
View File

@@ -0,0 +1,168 @@
package live
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"sync"
)
// Preset maps one PipeWire capture source to a set of Sendspin clients.
// Members are sendspin `client_id` values, not friendly names.
type Preset struct {
ID string `json:"id"`
Name string `json:"name"`
Source string `json:"source"`
Members []string `json:"members"`
}
var idRe = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
func (p *Preset) validate() error {
if p.ID == "" {
return fmt.Errorf("id is required")
}
if !idRe.MatchString(p.ID) {
return fmt.Errorf("id must be lowercase kebab-case")
}
if p.Name == "" {
return fmt.Errorf("name is required")
}
if p.Source == "" {
return fmt.Errorf("source is required")
}
return nil
}
// Store is an in-memory, file-backed preset collection. Writes are
// serialized; the file is rewritten atomically on every change.
type Store struct {
mu sync.RWMutex
path string
presets map[string]Preset
onWrite func()
}
func NewStore(path string) (*Store, error) {
s := &Store{
path: path,
presets: make(map[string]Preset),
}
if err := s.load(); err != nil {
return nil, err
}
return s, nil
}
// OnWrite registers a callback that runs after every successful write.
// Used by the manager to refresh active state when the active preset
// changes underneath it.
func (s *Store) OnWrite(fn func()) {
s.mu.Lock()
s.onWrite = fn
s.mu.Unlock()
}
func (s *Store) load() error {
data, err := os.ReadFile(s.path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if len(data) == 0 {
return nil
}
var list []Preset
if err := json.Unmarshal(data, &list); err != nil {
return fmt.Errorf("parse %s: %w", s.path, err)
}
for _, p := range list {
s.presets[p.ID] = p
}
return nil
}
func (s *Store) writeLocked() error {
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
return err
}
list := make([]Preset, 0, len(s.presets))
for _, p := range s.presets {
list = append(list, p)
}
sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID })
data, err := json.MarshalIndent(list, "", " ")
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
if err := os.Rename(tmp, s.path); err != nil {
return err
}
if s.onWrite != nil {
go s.onWrite()
}
return nil
}
func (s *Store) List() []Preset {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]Preset, 0, len(s.presets))
for _, p := range s.presets {
out = append(out, p)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
func (s *Store) Get(id string) (Preset, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
p, ok := s.presets[id]
return p, ok
}
func (s *Store) Put(p Preset) error {
if err := p.validate(); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
s.presets[p.ID] = p
return s.writeLocked()
}
// Create adds a preset, failing if the ID already exists.
func (s *Store) Create(p Preset) error {
if err := p.validate(); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.presets[p.ID]; exists {
return fmt.Errorf("preset %q already exists", p.ID)
}
s.presets[p.ID] = p
return s.writeLocked()
}
func (s *Store) Delete(id string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.presets[id]; !ok {
return false, nil
}
delete(s.presets, id)
return true, s.writeLocked()
}

58
internal/live/roster.go Normal file
View File

@@ -0,0 +1,58 @@
package live
import (
"sort"
"sync"
"time"
)
// RosterEntry is one observed Sendspin client. Populated from
// client/hello messages — independent of whether the connection was
// then accepted or rejected. This is what gives the UI the ability to
// show the *actual* `client_id` of every speaker on the network, even
// when ClientFilter rejected its current attempt.
type RosterEntry struct {
ClientID string `json:"client_id"`
Name string `json:"name"`
LastSeen time.Time `json:"last_seen"`
}
// Roster caches every (client_id, name) pair the sendspin server has
// observed via client/hello. Records are kept indefinitely — speakers
// usually keep the same client_id across reboots, so a stale entry
// remains useful when the user is configuring presets offline.
type Roster struct {
mu sync.RWMutex
entries map[string]RosterEntry
}
func NewRoster() *Roster {
return &Roster{entries: make(map[string]RosterEntry)}
}
// Record is intended to be plugged into ServerConfig.OnClientHello.
// Updates the entry's LastSeen on every call so the UI can show
// "active now" vs. "last seen 5 minutes ago".
func (r *Roster) Record(clientID, name string) {
if clientID == "" {
return
}
r.mu.Lock()
defer r.mu.Unlock()
r.entries[clientID] = RosterEntry{
ClientID: clientID,
Name: name,
LastSeen: time.Now(),
}
}
func (r *Roster) List() []RosterEntry {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]RosterEntry, 0, len(r.entries))
for _, e := range r.entries {
out = append(out, e)
}
sort.Slice(out, func(i, j int) bool { return out[i].ClientID < out[j].ClientID })
return out
}

141
internal/live/source.go Normal file
View File

@@ -0,0 +1,141 @@
package live
import (
"log"
"sync"
"time"
"rpi-sendspin/internal/pipewire"
)
// Source is the sendspin AudioSource used by the live server. It wraps
// a single pipewire.Source instance and exposes runtime control:
//
// - SetTarget swaps the captured PipeWire node and recreates the
// underlying pw-cat process.
// - SetActive(false) makes Read() return (0, nil) on every call so the
// sendspin audio engine skips its tick without surfacing an error.
// pw-cat is killed in this mode so we don't burn CPU capturing audio
// no one is listening to.
//
// The sample rate / channel count are fixed at construction; v1 of the
// live server does not retune between presets.
type Source struct {
sampleRate int
channels int
mu sync.Mutex
target string
active bool
pw *pipewire.Source
retryAt time.Time // earliest time the next pw-cat start may be attempted
failures int // consecutive failures, drives exponential backoff
lastError string // last error message; logged once per change to avoid spam
}
const (
backoffMin = 500 * time.Millisecond
backoffMax = 10 * time.Second
)
func NewSource(sampleRate, channels int) *Source {
return &Source{
sampleRate: sampleRate,
channels: channels,
}
}
// SetTarget sets the PipeWire node to capture from. If the target
// changes while the source is active, the underlying pw-cat process is
// torn down and recreated on the next Read.
func (s *Source) SetTarget(target string) {
s.mu.Lock()
defer s.mu.Unlock()
if s.target == target {
return
}
s.target = target
s.failures = 0
s.retryAt = time.Time{}
s.lastError = ""
s.teardownLocked()
}
// SetActive controls whether Read pulls from pw-cat. When set to false
// any running pw-cat is killed and subsequent Reads return (0, nil).
func (s *Source) SetActive(active bool) {
s.mu.Lock()
defer s.mu.Unlock()
if s.active == active {
return
}
s.active = active
if !active {
s.teardownLocked()
}
}
func (s *Source) teardownLocked() {
if s.pw != nil {
_ = s.pw.Close()
s.pw = nil
}
}
func (s *Source) Read(samples []int32) (int, error) {
s.mu.Lock()
if !s.active || s.target == "" {
s.mu.Unlock()
return 0, nil
}
// Backoff: when pw-cat keeps dying (e.g. invalid target), don't
// fork-bomb a new process on every 20 ms tick. Return (0, nil) so
// the sendspin audio engine skips the tick silently until retryAt.
if !s.retryAt.IsZero() && time.Now().Before(s.retryAt) {
s.mu.Unlock()
return 0, nil
}
if s.pw == nil {
s.pw = pipewire.NewSource(s.target, s.sampleRate, s.channels)
}
pw := s.pw
target := s.target
s.mu.Unlock()
n, err := pw.Read(samples)
if err != nil {
s.mu.Lock()
s.teardownLocked()
s.failures++
backoff := backoffMin << (s.failures - 1)
if backoff > backoffMax || backoff <= 0 {
backoff = backoffMax
}
s.retryAt = time.Now().Add(backoff)
msg := err.Error()
if msg != s.lastError {
log.Printf("pw-cat[%s] read error: %v (retry in %s)", target, err, backoff)
s.lastError = msg
}
s.mu.Unlock()
return 0, nil
}
if n > 0 {
s.mu.Lock()
s.failures = 0
s.lastError = ""
s.mu.Unlock()
}
return n, nil
}
func (s *Source) SampleRate() int { return s.sampleRate }
func (s *Source) Channels() int { return s.channels }
func (s *Source) Metadata() (string, string, string) { return "", "", "" }
func (s *Source) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
s.teardownLocked()
return nil
}

19
internal/live/web.go Normal file
View File

@@ -0,0 +1,19 @@
package live
import (
"embed"
"io/fs"
"net/http"
)
//go:embed web/index.html
var webFS embed.FS
func staticHandler() http.Handler {
sub, err := fs.Sub(webFS, "web")
if err != nil {
// Embed misconfiguration is a build-time bug; fall back to 404.
return http.NotFoundHandler()
}
return http.FileServer(http.FS(sub))
}

View File

@@ -0,0 +1,593 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Sendspin Live</title>
<style>
:root {
color-scheme: dark;
--bg: #0f1115;
--panel: #161a22;
--panel2: #1d222c;
--border: #2a303c;
--text: #e6e9ef;
--muted: #8b94a7;
--accent: #4c9aff;
--accent-hover: #3a86e8;
--good: #4ade80;
--bad: #ef4444;
--warn: #f59e0b;
}
* { box-sizing: border-box; }
body {
margin: 0;
font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
background: var(--bg);
color: var(--text);
}
header {
border-bottom: 1px solid var(--border);
padding: 14px 24px;
display: flex;
align-items: center;
gap: 20px;
flex-wrap: wrap;
background: var(--panel);
}
header h1 {
font-size: 16px;
font-weight: 600;
margin: 0;
margin-right: auto;
letter-spacing: 0.3px;
}
.pill {
padding: 2px 10px;
border-radius: 999px;
background: var(--panel2);
color: var(--muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.pill.playing { background: rgba(74,222,128,0.15); color: var(--good); }
.pill.paused { background: rgba(245,158,11,0.15); color: var(--warn); }
.pill.off { background: rgba(139,148,167,0.15); color: var(--muted); }
main {
max-width: 1100px;
margin: 24px auto;
padding: 0 24px 80px;
display: grid;
gap: 24px;
grid-template-columns: 1fr 1fr;
}
@media (max-width: 800px) { main { grid-template-columns: 1fr; } }
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 18px 20px;
}
.card h2 {
margin: 0 0 14px;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--muted);
display: flex;
align-items: center;
justify-content: space-between;
}
.row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 0;
border-top: 1px solid var(--border);
}
.row:first-of-type { border-top: none; }
.row .grow { flex: 1; }
.muted { color: var(--muted); font-size: 12px; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
button, select, input[type=text], input[type=number] {
background: var(--panel2);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 7px 10px;
font: inherit;
}
button { cursor: pointer; }
button:hover:not(:disabled) { border-color: var(--accent); }
button.primary {
background: var(--accent);
color: white;
border-color: var(--accent);
}
button.primary:hover:not(:disabled) {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
button.danger { color: var(--bad); }
button.danger:hover:not(:disabled) { border-color: var(--bad); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-group { display: inline-flex; gap: 6px; }
.dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--muted);
}
.dot.connected { background: var(--good); }
.dot.discovered { background: var(--warn); }
input[type=range] { flex: 1; accent-color: var(--accent); }
label { display: block; margin: 12px 0 4px; font-size: 12px; color: var(--muted); }
label.inline { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text); margin: 0; }
.stack > * + * { margin-top: 14px; }
.badge {
display: inline-block;
padding: 1px 8px;
border-radius: 4px;
background: var(--panel2);
color: var(--muted);
font-size: 11px;
}
.badge.active { background: rgba(76,154,255,0.15); color: var(--accent); }
.members-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 240px;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px;
background: var(--panel2);
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
display: none;
align-items: center;
justify-content: center;
z-index: 50;
}
.modal-overlay.open { display: flex; }
.modal {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 22px;
width: min(500px, 92vw);
max-height: 90vh;
overflow-y: auto;
}
.modal h3 { margin: 0 0 14px; font-size: 16px; }
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 18px;
}
.empty {
text-align: center;
color: var(--muted);
padding: 18px 0;
font-size: 13px;
}
input[type=text]:focus, input[type=number]:focus, select:focus {
outline: none;
border-color: var(--accent);
}
</style>
</head>
<body>
<header>
<h1>Sendspin Live</h1>
<span id="state-pill" class="pill off">off</span>
<span id="active-pill" class="pill"></span>
</header>
<main>
<section class="card">
<h2>
Now playing
</h2>
<div class="stack">
<div class="row">
<span class="grow">Active preset</span>
<select id="active-preset"></select>
</div>
<div class="row">
<span class="grow">Playback</span>
<div class="btn-group">
<button id="btn-play">Play</button>
<button id="btn-pause">Pause</button>
<button id="btn-stop">Stop</button>
</div>
</div>
<div class="row">
<span class="grow">Volume <span id="volume-label" class="mono"></span></span>
</div>
<div class="row">
<input id="volume" type="range" min="0" max="100" step="1" />
</div>
<div class="row">
<span class="grow">Members</span>
</div>
<div id="members" class="muted"></div>
</div>
</section>
<section class="card">
<h2>
Discovered clients
<button id="btn-refresh-clients" class="muted" style="background:none;border:none;font-size:12px;">refresh</button>
</h2>
<div id="clients"></div>
</section>
<section class="card" style="grid-column: 1 / -1;">
<h2>
Presets
<button id="btn-new-preset" class="primary">New preset</button>
</h2>
<div id="presets"></div>
</section>
</main>
<div class="modal-overlay" id="modal">
<div class="modal">
<h3 id="modal-title">New preset</h3>
<form id="preset-form" autocomplete="off">
<label for="f-id">ID <span class="muted">(lowercase, kebab-case)</span></label>
<input id="f-id" type="text" required pattern="[a-z0-9]+(-[a-z0-9]+)*" style="width:100%" />
<label for="f-name">Name</label>
<input id="f-name" type="text" required style="width:100%" />
<label for="f-source">PipeWire source</label>
<select id="f-source" required style="width:100%"></select>
<label>Members</label>
<div class="members-list" id="f-members"></div>
<label for="f-custom-member">Add member by client_id</label>
<div style="display:flex;gap:6px;">
<input id="f-custom-member" type="text" placeholder="speaker-living-room" style="flex:1" />
<button type="button" id="f-add-member">Add</button>
</div>
<div class="modal-actions">
<button type="button" id="f-cancel">Cancel</button>
<button type="submit" class="primary" id="f-save">Save</button>
</div>
</form>
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
let state = { active_preset: "", playback: "off", volume: 0, members: [] };
let presets = [];
let clients = [];
let sources = [];
let editing = null; // preset being edited, or null for new
let formMembers = []; // strings (client_ids)
let volumeDirty = false; // user holding the slider — don't clobber
async function api(method, path, body) {
const opts = { method, headers: { "content-type": "application/json" } };
if (body !== undefined) opts.body = JSON.stringify(body);
const r = await fetch(path, opts);
if (!r.ok) {
let msg = r.statusText;
try { msg = (await r.json()).error || msg; } catch {}
throw new Error(`${method} ${path}: ${msg}`);
}
if (r.status === 204) return null;
return r.json();
}
async function refreshAll() {
try {
[state, presets, clients, sources] = await Promise.all([
api("GET", "/api/state"),
api("GET", "/api/presets"),
api("GET", "/api/clients"),
api("GET", "/api/sources"),
]);
render();
} catch (e) {
console.error(e);
}
}
function render() {
// header pills
const sp = $("state-pill");
sp.className = "pill " + state.playback;
sp.textContent = state.playback;
const ap = $("active-pill");
if (state.active_preset) {
const p = presets.find(x => x.id === state.active_preset);
ap.textContent = p ? p.name : state.active_preset;
ap.style.display = "";
} else {
ap.style.display = "none";
}
// active preset selector
const sel = $("active-preset");
sel.innerHTML = "";
const noneOpt = document.createElement("option");
noneOpt.value = ""; noneOpt.textContent = "— off —";
sel.appendChild(noneOpt);
for (const p of presets) {
const o = document.createElement("option");
o.value = p.id; o.textContent = p.name;
sel.appendChild(o);
}
sel.value = state.active_preset || "";
// playback buttons
$("btn-play").disabled = !state.active_preset || state.playback === "playing";
$("btn-pause").disabled = state.playback !== "playing";
$("btn-stop").disabled = !state.active_preset;
// volume slider
$("volume-label").textContent = state.volume + "%";
if (!volumeDirty) $("volume").value = state.volume;
$("volume").disabled = !state.active_preset;
// members of the active preset
const mEl = $("members");
if (!state.members || !state.members.length) {
mEl.innerHTML = '<div class="empty">No active preset</div>';
} else {
mEl.innerHTML = "";
for (const m of state.members) {
const div = document.createElement("div");
div.className = "row";
div.innerHTML = `
<span class="dot ${m.connected ? 'connected' : ''}"></span>
<span class="grow mono">${escapeHTML(m.client_id)}</span>
<span class="muted">${m.connected ? `vol ${m.volume}${m.muted ? ' · muted' : ''}` : 'offline'}</span>
`;
mEl.appendChild(div);
}
}
// discovered clients
const cEl = $("clients");
if (!clients.length) {
cEl.innerHTML = '<div class="empty">No clients discovered yet</div>';
} else {
cEl.innerHTML = "";
for (const c of clients) {
const dot = c.connected ? "connected" : "discovered";
const id = c.client_id || c.instance || "—";
const label = c.name || c.instance || id;
let badge;
if (c.source === "connected") {
badge = '<span class="badge active">connected</span>';
} else if (c.source === "roster") {
badge = '<span class="badge" title="Speaker has handshaked at least once; we know its client_id">known</span>';
} else {
badge = '<span class="badge" title="Only seen via mDNS; never handshaked, so client_id is not yet known">mDNS only</span>';
}
const div = document.createElement("div");
div.className = "row";
div.innerHTML = `
<span class="dot ${dot}"></span>
<span class="grow">
<div>${escapeHTML(label)}</div>
<div class="mono muted">${escapeHTML(id)}</div>
</span>
${badge}
`;
cEl.appendChild(div);
}
}
// presets list
const pEl = $("presets");
if (!presets.length) {
pEl.innerHTML = '<div class="empty">No presets — click "New preset" to create one.</div>';
} else {
pEl.innerHTML = "";
for (const p of presets) {
const isActive = p.id === state.active_preset;
const div = document.createElement("div");
div.className = "row";
div.innerHTML = `
<span class="grow">
<div>
${escapeHTML(p.name)}
${isActive ? '<span class="badge active">active</span>' : ''}
</div>
<div class="mono muted">${escapeHTML(p.id)} · ${escapeHTML(p.source)} · ${p.members.length} member${p.members.length === 1 ? '' : 's'}</div>
</span>
<div class="btn-group">
<button data-act="activate" data-id="${escapeAttr(p.id)}" ${isActive ? 'disabled' : ''}>Activate</button>
<button data-act="edit" data-id="${escapeAttr(p.id)}">Edit</button>
<button data-act="delete" data-id="${escapeAttr(p.id)}" class="danger">Delete</button>
</div>
`;
pEl.appendChild(div);
}
}
}
function escapeHTML(s) { return String(s).replace(/[&<>"']/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c])); }
function escapeAttr(s) { return escapeHTML(s); }
$("active-preset").addEventListener("change", async (e) => {
try { await api("POST", "/api/active_preset", { id: e.target.value }); }
catch (err) { alert(err.message); }
refreshAll();
});
$("btn-play").onclick = async () => { await api("POST", "/api/playback", { command: "play" }).catch(e => alert(e.message)); refreshAll(); };
$("btn-pause").onclick = async () => { await api("POST", "/api/playback", { command: "pause" }).catch(e => alert(e.message)); refreshAll(); };
$("btn-stop").onclick = async () => { await api("POST", "/api/playback", { command: "stop" }).catch(e => alert(e.message)); refreshAll(); };
const vol = $("volume");
vol.addEventListener("input", () => { volumeDirty = true; $("volume-label").textContent = vol.value + "%"; });
vol.addEventListener("change", async () => {
try { await api("POST", "/api/volume", { volume: parseInt(vol.value, 10) }); }
catch (e) { alert(e.message); }
volumeDirty = false;
refreshAll();
});
$("btn-refresh-clients").onclick = refreshAll;
$("btn-new-preset").onclick = () => openModal(null);
document.getElementById("presets").addEventListener("click", async (e) => {
const btn = e.target.closest("button[data-act]");
if (!btn) return;
const id = btn.dataset.id;
if (btn.dataset.act === "activate") {
try { await api("POST", "/api/active_preset", { id }); } catch (err) { alert(err.message); }
refreshAll();
} else if (btn.dataset.act === "edit") {
const p = presets.find(x => x.id === id);
if (p) openModal(p);
} else if (btn.dataset.act === "delete") {
if (!confirm(`Delete preset "${id}"?`)) return;
try { await api("DELETE", "/api/presets/" + encodeURIComponent(id)); } catch (err) { alert(err.message); }
refreshAll();
}
});
function openModal(p) {
editing = p;
$("modal-title").textContent = p ? "Edit preset" : "New preset";
$("f-id").value = p ? p.id : "";
$("f-id").disabled = !!p;
$("f-name").value = p ? p.name : "";
formMembers = p ? [...p.members] : [];
// source dropdown
const src = $("f-source");
src.innerHTML = "";
const known = new Set(sources.map(s => s.name));
if (p && !known.has(p.source)) {
// preserve current source even if PipeWire doesn't currently advertise it
const o = document.createElement("option");
o.value = p.source;
o.textContent = p.source + " (not found)";
src.appendChild(o);
}
// Group monitors vs real inputs into optgroups so "stream what's
// playing" is the obvious choice.
const monitorGrp = document.createElement("optgroup");
monitorGrp.label = "Monitors (capture playback)";
const inputGrp = document.createElement("optgroup");
inputGrp.label = "Inputs (microphones, line-in)";
for (const s of sources) {
const o = document.createElement("option");
o.value = s.name;
const desc = s.description || s.name;
o.textContent = s.description ? `${desc} [${s.name}]` : s.name;
if (s.is_monitor) {
monitorGrp.appendChild(o);
} else {
inputGrp.appendChild(o);
}
}
if (monitorGrp.children.length) src.appendChild(monitorGrp);
if (inputGrp.children.length) src.appendChild(inputGrp);
if (p) src.value = p.source;
renderFormMembers();
$("modal").classList.add("open");
$("f-name").focus();
}
function closeModal() { $("modal").classList.remove("open"); editing = null; }
function renderFormMembers() {
const el = $("f-members");
el.innerHTML = "";
// Only entries with a known client_id (connected or roster) can be
// checked on. mDNS-only entries don't expose a client_id, so they
// can't be safely used as preset members — the user must wait for
// the speaker to handshake once, or paste its client_id below.
const knownIDs = new Map();
for (const c of clients) {
if (!c.client_id) continue;
if (!knownIDs.has(c.client_id)) {
knownIDs.set(c.client_id, { id: c.client_id, label: c.name || c.client_id, connected: !!c.connected });
}
}
for (const id of formMembers) {
if (!knownIDs.has(id)) knownIDs.set(id, { id, label: id, connected: false });
}
if (knownIDs.size === 0) {
el.innerHTML = '<div class="muted">No speakers have handshaked yet. Make sure they\'re on the network, or add one by client_id below.</div>';
return;
}
for (const entry of knownIDs.values()) {
const id = entry.id;
const checked = formMembers.includes(id);
const row = document.createElement("label");
row.className = "inline";
row.innerHTML = `
<input type="checkbox" data-id="${escapeAttr(id)}" ${checked ? 'checked' : ''} />
<span class="dot ${entry.connected ? 'connected' : ''}"></span>
<span class="grow">${escapeHTML(entry.label)}</span>
<span class="mono muted">${escapeHTML(id)}</span>
`;
row.querySelector("input").addEventListener("change", (e) => {
if (e.target.checked) {
if (!formMembers.includes(id)) formMembers.push(id);
} else {
formMembers = formMembers.filter(m => m !== id);
}
});
el.appendChild(row);
}
}
$("f-add-member").onclick = () => {
const input = $("f-custom-member");
const id = input.value.trim();
if (!id) return;
if (!formMembers.includes(id)) formMembers.push(id);
input.value = "";
renderFormMembers();
};
$("f-cancel").onclick = closeModal;
$("modal").addEventListener("click", (e) => { if (e.target.id === "modal") closeModal(); });
$("preset-form").addEventListener("submit", async (e) => {
e.preventDefault();
const payload = {
id: $("f-id").value.trim(),
name: $("f-name").value.trim(),
source: $("f-source").value,
members: formMembers,
};
try {
if (editing) {
await api("PUT", "/api/presets/" + encodeURIComponent(payload.id), payload);
} else {
await api("POST", "/api/presets", payload);
}
closeModal();
refreshAll();
} catch (err) {
alert(err.message);
}
});
refreshAll();
setInterval(refreshAll, 3000);
</script>
</body>
</html>

155
internal/pipewire/nodes.go Normal file
View File

@@ -0,0 +1,155 @@
package pipewire
import (
"encoding/json"
"fmt"
"os/exec"
"strings"
)
type Node struct {
ID int
Name string
Description string
MediaClass string
}
func (n Node) String() string {
if n.Description != "" && n.Description != n.Name {
return fmt.Sprintf("%s [%s]", n.Description, n.Name)
}
return n.Name
}
type pwObject struct {
ID int `json:"id"`
Type string `json:"type"`
Info struct {
Props struct {
MediaClass string `json:"media.class"`
NodeName string `json:"node.name"`
NodeDesc string `json:"node.description"`
} `json:"props"`
} `json:"info"`
}
func listAllNodes() ([]Node, error) {
out, err := exec.Command("pw-dump").Output()
if err != nil {
return nil, fmt.Errorf("pw-dump: %w", err)
}
var objects []pwObject
if err := json.Unmarshal(out, &objects); err != nil {
return nil, fmt.Errorf("parse pw-dump: %w", err)
}
var nodes []Node
for _, o := range objects {
if o.Type != "PipeWire:Interface:Node" {
continue
}
if !strings.HasPrefix(o.Info.Props.MediaClass, "Audio/") || o.Info.Props.NodeName == "" {
continue
}
nodes = append(nodes, Node{
ID: o.ID,
Name: o.Info.Props.NodeName,
Description: o.Info.Props.NodeDesc,
MediaClass: o.Info.Props.MediaClass,
})
}
return nodes, nil
}
func ListSinks() ([]Node, error) {
all, err := listAllNodes()
if err != nil {
return nil, err
}
var out []Node
for _, n := range all {
if n.MediaClass == "Audio/Sink" {
out = append(out, n)
}
}
return out, nil
}
func ListSources() ([]Node, error) {
all, err := listAllNodes()
if err != nil {
return nil, err
}
var out []Node
for _, n := range all {
switch n.MediaClass {
case "Audio/Source", "Audio/Source.Virtual":
out = append(out, n)
}
}
return out, nil
}
// CaptureTarget is a node that can be fed to `pw-cat --capture --target`.
// Real microphones / line-ins / virtual sources have IsMonitor=false.
// Audio sinks are surfaced with IsMonitor=true: pw-cat reads the monitor
// port when given a sink as --target, so the live server can stream
// whatever audio the host is currently playing.
type CaptureTarget struct {
Name string
Description string
IsMonitor bool
}
// ListCaptureTargets returns every PipeWire node usable as audio input
// for the live server. Sinks are surfaced as monitor capture targets
// (pw-cat captures the monitor port when given a sink as --target);
// `.monitor` pseudo-sources exposed by the PulseAudio compatibility
// layer are also included since `pw-cat --target <name>.monitor` is
// the canonical way to capture playback on many setups.
//
// Order: real microphones / line-ins first, then monitors (the things
// most users actually want for "stream what's playing").
func ListCaptureTargets() ([]CaptureTarget, error) {
all, err := listAllNodes()
if err != nil {
return nil, err
}
var realSources, monitorSources, sinkMonitors []CaptureTarget
monitorNames := map[string]bool{}
for _, n := range all {
switch n.MediaClass {
case "Audio/Source", "Audio/Source.Virtual":
if strings.HasSuffix(n.Name, ".monitor") {
monitorSources = append(monitorSources, CaptureTarget{
Name: n.Name, Description: n.Description, IsMonitor: true,
})
monitorNames[n.Name] = true
} else {
realSources = append(realSources, CaptureTarget{
Name: n.Name, Description: n.Description,
})
}
case "Audio/Sink":
// Skip the sink itself when its corresponding ".monitor"
// source already covers the same capture path — pick one
// canonical name (the source) to avoid two indistinguishable
// dropdown entries.
if monitorNames[n.Name+".monitor"] {
continue
}
sinkMonitors = append(sinkMonitors, CaptureTarget{
Name: n.Name, Description: n.Description, IsMonitor: true,
})
}
}
out := make([]CaptureTarget, 0, len(realSources)+len(monitorSources)+len(sinkMonitors))
out = append(out, realSources...)
out = append(out, monitorSources...)
out = append(out, sinkMonitors...)
return out, nil
}

165
internal/pipewire/output.go Normal file
View File

@@ -0,0 +1,165 @@
package pipewire
import (
"encoding/binary"
"encoding/json"
"fmt"
"io"
"log"
"os/exec"
"strconv"
"strings"
"sync"
)
// Output implements the sendspin output.Output interface via pw-cat --playback.
type Output struct {
target string
nodeName string
mu sync.Mutex
cmd *exec.Cmd
stdin io.WriteCloser
sampleRate int
channels int
bitShift uint
buf []byte
volume float64
muted bool
}
func NewOutput(target, nodeName string) *Output {
return &Output{
target: target,
nodeName: nodeName,
volume: 1.0,
}
}
func (o *Output) Open(sampleRate, channels, bitDepth int) error {
o.mu.Lock()
defer o.mu.Unlock()
o.sampleRate = sampleRate
o.channels = channels
// Samples from sendspin are right-justified in int32; shift to fill
// the full 32-bit range so pw-cat receives proper amplitude.
if bitDepth > 0 && bitDepth < 32 {
o.bitShift = uint(32 - bitDepth)
} else {
o.bitShift = 0
}
o.killUnlocked()
return o.startUnlocked()
}
func (o *Output) startUnlocked() error {
nameJSON, _ := json.Marshal(o.nodeName)
props := fmt.Sprintf(`{"node.name":%s}`, nameJSON)
args := []string{
"--playback",
"--raw",
"--format", "s32",
"--rate", strconv.Itoa(o.sampleRate),
"--channels", strconv.Itoa(o.channels),
"--properties", props,
}
if o.target != "" {
args = append(args, "--target", o.target)
}
args = append(args, "-")
cmd := exec.Command("pw-cat", args...)
stderr, _ := cmd.StderrPipe()
stdin, err := cmd.StdinPipe()
if err != nil {
return fmt.Errorf("pw-cat stdin: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("pw-cat start: %w", err)
}
go func() {
b, _ := io.ReadAll(stderr)
if s := strings.TrimSpace(string(b)); s != "" {
log.Printf("pw-cat stderr: %s", s)
}
}()
o.cmd = cmd
o.stdin = stdin
if o.buf == nil {
o.buf = make([]byte, 0, 4096)
}
return nil
}
func (o *Output) killUnlocked() {
if o.stdin != nil {
o.stdin.Close()
o.stdin = nil
}
if o.cmd != nil && o.cmd.Process != nil {
o.cmd.Process.Kill()
o.cmd.Wait()
o.cmd = nil
}
}
func (o *Output) Write(samples []int32) error {
o.mu.Lock()
defer o.mu.Unlock()
if o.stdin == nil {
return fmt.Errorf("output not opened")
}
need := len(samples) * 4
if cap(o.buf) < need {
o.buf = make([]byte, need)
}
o.buf = o.buf[:need]
for i, s := range samples {
if o.muted {
s = 0
} else if o.volume != 1.0 {
s = int32(float64(s) * o.volume)
}
s <<= o.bitShift
binary.LittleEndian.PutUint32(o.buf[i*4:], uint32(s))
}
_, err := o.stdin.Write(o.buf)
if err != nil {
log.Printf("pw-cat pipe broken (%v), restarting", err)
o.killUnlocked()
if rerr := o.startUnlocked(); rerr != nil {
return fmt.Errorf("pw-cat restart: %w", rerr)
}
_, err = o.stdin.Write(o.buf)
}
return err
}
func (o *Output) SetVolume(volume int) {
o.mu.Lock()
o.volume = float64(volume) / 100.0
o.mu.Unlock()
}
func (o *Output) SetMuted(muted bool) {
o.mu.Lock()
o.muted = muted
o.mu.Unlock()
}
func (o *Output) Close() error {
o.mu.Lock()
defer o.mu.Unlock()
o.killUnlocked()
return nil
}

484
internal/pipewire/source.go Normal file
View File

@@ -0,0 +1,484 @@
package pipewire
import (
"context"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"log"
"os/exec"
"strconv"
"strings"
"sync"
"time"
)
// Capture-jitter telemetry: log a stats line every readStatsInterval reads
// (avg/min/max ReadFull latency) plus an immediate warning whenever a single
// read exceeds readSpikeThreshold. pw-cat produces samples in real time, so
// the expected per-read latency is ~ChunkDurationMs (20ms). Spikes there are
// the most likely on-device cause of audible drops.
const (
readSpikeThreshold = 40 * time.Millisecond
readStatsInterval = 250 // ~5s at 20ms chunks
)
// Source implements the sendspin AudioSource interface via pw-cat --record.
//
// To avoid PipeWire's session manager auto-connecting our recorder to whatever
// the "default" source is (typically the mic on hardware that has one — which
// silently steals capture from a requested `.monitor` target), we start pw-cat
// with node.autoconnect=false and a unique node.name, then look up both nodes
// in pw-dump and pw-link the target's output ports to our input ports
// manually.
type Source struct {
target string
sampleRate int
channels int
mu sync.Mutex
cmd *exec.Cmd
stdout io.ReadCloser
buf []byte
// dataCh carries raw byte chunks from a background goroutine that
// drains pw-cat as fast as bytes appear. Read assembles ChunkDurationMs
// worth of samples from this stream — without this decoupling, Read
// would block ~21ms per call because PipeWire's default graph quantum
// (1024 samples at 48kHz) exceeds our 20ms tick budget, pushing the
// engine ~1.3ms behind real-time per chunk and eroding the 500ms
// client buffer-ahead until chunks land late and audio drops.
dataCh chan []byte
leftover []byte
stopDrain chan struct{}
drainErr error
drainMu sync.Mutex
// jitter telemetry — accessed only from Read so no extra locking
readCount int
readSumNanos int64
readMaxNanos int64
readMinNanos int64
}
func NewSource(target string, sampleRate, channels int) *Source {
return &Source{
target: target,
sampleRate: sampleRate,
channels: channels,
}
}
func (s *Source) start() error {
uid := fmt.Sprintf("sendspin-cap-%d", time.Now().UnixNano())
uidJSON, _ := json.Marshal(uid)
// node.latency requests a 20ms quantum (960 samples at 48kHz). Without
// this, PipeWire defaults to its graph quantum (typically 1024 samples
// / 21.33ms), which means pw-cat delivers in bursts that don't align
// with the sendspin engine's 20ms ticker — every chunk ends up 1.3ms
// over budget and the 500ms client buffer-ahead drifts away.
chunkSamples := (s.sampleRate * 20) / 1000
props := fmt.Sprintf(
`{"node.name":%s,"node.description":"Sendspin capture","node.autoconnect":false,"node.latency":"%d/%d"}`,
uidJSON, chunkSamples, s.sampleRate,
)
args := []string{
"--record",
"--raw",
"--format", "s32",
"--rate", strconv.Itoa(s.sampleRate),
"--channels", strconv.Itoa(s.channels),
"--target", s.target,
"--properties", props,
"-",
}
log.Printf("pw-cat %s", strings.Join(args, " "))
cmd := exec.Command("pw-cat", args...)
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("pw-cat stdout: %w", err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
return fmt.Errorf("pw-cat stderr: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("pw-cat start: %w", err)
}
go func(t string) {
buf, _ := io.ReadAll(stderr)
if msg := strings.TrimSpace(string(buf)); msg != "" {
log.Printf("pw-cat[%s] stderr: %s", t, msg)
}
}(s.target)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
outIDs, inIDs, linkErr := waitAndMatchPorts(ctx, s.target, uid)
if linkErr != nil {
_ = stdout.Close()
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
_ = cmd.Wait()
return fmt.Errorf("link target ports: %w", linkErr)
}
for i := range outIDs {
lc := exec.Command("pw-link", strconv.Itoa(outIDs[i]), strconv.Itoa(inIDs[i]))
if out, err := lc.CombinedOutput(); err != nil {
log.Printf("pw-link %d→%d: %v: %s", outIDs[i], inIDs[i], err, out)
}
}
s.cmd = cmd
s.stdout = stdout
s.buf = make([]byte, 4096)
// Buffered channel sized to ~1s of audio (50 × 20ms chunks). Plenty
// of headroom for pw-cat's quantum bursts; if it ever fills, pw-cat
// blocks on its stdout write — at which point the engine is so far
// behind that backpressure is the right behavior.
s.dataCh = make(chan []byte, 50)
s.stopDrain = make(chan struct{})
go s.drain(stdout, s.stopDrain)
// Pre-buffer ~60ms (3 quanta) before letting Read return. Two quanta
// (~43ms) is the minimum to absorb the 1024-vs-960-sample beat
// pattern; 60ms gives a small safety margin for scheduler jitter
// without adding meaningful startup latency.
preBufferBytes := (60 * s.sampleRate / 1000) * 4 * s.channels
prebufStart := time.Now()
have := 0
var queued [][]byte
for have < preBufferBytes {
select {
case b := <-s.dataCh:
queued = append(queued, b)
have += len(b)
case <-time.After(2 * time.Second):
_ = stdout.Close()
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
_ = cmd.Wait()
return fmt.Errorf("pw-cat pre-buffer timeout (got %d/%d bytes in %s)",
have, preBufferBytes, time.Since(prebufStart))
}
}
// Re-prime the channel with what we already drained so Read sees
// the full pre-buffer. Channel capacity (50) > expected queue size
// (~5 slices), so this never blocks.
for _, b := range queued {
s.dataCh <- b
}
log.Printf("pw-cat pre-buffered %d bytes (%dms) in %s",
have, have/(48*4*2), time.Since(prebufStart).Round(time.Millisecond))
return nil
}
// drain continuously reads from pw-cat's stdout and pushes byte slices
// onto dataCh. Runs until stdout closes or stop is signaled. Each iteration
// allocates a fresh slice so the receiver owns it — pool/reuse here would
// race with Read.
func (s *Source) drain(stdout io.ReadCloser, stop <-chan struct{}) {
buf := make([]byte, 8192)
for {
n, err := stdout.Read(buf)
if n > 0 {
chunk := make([]byte, n)
copy(chunk, buf[:n])
select {
case s.dataCh <- chunk:
case <-stop:
return
}
}
if err != nil {
s.drainMu.Lock()
s.drainErr = err
s.drainMu.Unlock()
close(s.dataCh)
return
}
select {
case <-stop:
return
default:
}
}
}
func (s *Source) Read(samples []int32) (int, error) {
s.mu.Lock()
if s.cmd == nil {
if err := s.start(); err != nil {
s.mu.Unlock()
return 0, err
}
}
need := len(samples) * 4
if len(s.buf) < need {
s.buf = make([]byte, need)
}
buf := s.buf[:need]
s.mu.Unlock()
readStart := time.Now()
filled := 0
// Drain any leftover bytes from the previous Read first. pw-cat's
// quantum (1024 samples) doesn't align with our request (960 samples),
// so every other call carries 64 samples' worth of bytes forward.
if len(s.leftover) > 0 {
copied := copy(buf[filled:], s.leftover)
filled += copied
s.leftover = s.leftover[copied:]
}
for filled < need {
chunk, ok := <-s.dataCh
if !ok {
s.drainMu.Lock()
err := s.drainErr
s.drainMu.Unlock()
s.mu.Lock()
s.killUnlocked()
s.mu.Unlock()
if err == nil {
err = io.EOF
}
return filled / 4, err
}
copied := copy(buf[filled:], chunk)
filled += copied
if copied < len(chunk) {
s.leftover = chunk[copied:]
}
}
s.recordReadTiming(time.Since(readStart), need)
count := filled / 4
// pw-cat emits full-range int32; sendspin's AudioSource contract is
// int32 samples right-justified to 24 bits (encodePCM packs the low
// three bytes; opus does `int16(s >> 8)`). Without this shift the
// signal's top byte is discarded and only low-byte dither survives,
// producing noise that tracks input amplitude.
for i := range count {
samples[i] = int32(binary.LittleEndian.Uint32(buf[i*4:i*4+4])) >> 8
}
return count, nil
}
// recordReadTiming accumulates per-read latency and emits both spike
// warnings and periodic stats summaries. The expected baseline is roughly
// the chunk duration the engine asks for, since pw-cat produces real-time
// samples and io.ReadFull blocks until that many bytes are available.
func (s *Source) recordReadTiming(dur time.Duration, byteCount int) {
d := dur.Nanoseconds()
s.readCount++
s.readSumNanos += d
if d > s.readMaxNanos {
s.readMaxNanos = d
}
if s.readMinNanos == 0 || d < s.readMinNanos {
s.readMinNanos = d
}
if dur > readSpikeThreshold {
log.Printf("pw-cat read spike: %s for %d bytes (threshold %s)",
dur.Round(time.Microsecond), byteCount, readSpikeThreshold)
}
if s.readCount >= readStatsInterval {
avg := time.Duration(s.readSumNanos / int64(s.readCount))
log.Printf("pw-cat read stats over %d reads: avg=%s min=%s max=%s",
s.readCount,
avg.Round(time.Microsecond),
time.Duration(s.readMinNanos).Round(time.Microsecond),
time.Duration(s.readMaxNanos).Round(time.Microsecond),
)
s.readCount = 0
s.readSumNanos = 0
s.readMaxNanos = 0
s.readMinNanos = 0
}
}
// killUnlocked tears down the running pw-cat without holding the mutex.
// Callers must hold s.mu.
func (s *Source) killUnlocked() {
if s.stopDrain != nil {
// Signal the drainer to exit. It will also exit on its own when
// stdout closes, but signaling first avoids a hung send into
// dataCh if the buffer is full at shutdown.
close(s.stopDrain)
s.stopDrain = nil
}
if s.stdout != nil {
s.stdout.Close()
s.stdout = nil
}
if s.cmd != nil && s.cmd.Process != nil {
s.cmd.Process.Kill()
s.cmd.Wait()
s.cmd = nil
}
s.leftover = nil
}
func (s *Source) SampleRate() int { return s.sampleRate }
func (s *Source) Channels() int { return s.channels }
func (s *Source) Metadata() (string, string, string) { return "", "", "" }
func (s *Source) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
s.killUnlocked()
return nil
}
type portEntry struct {
id int
channel string
}
type dumpObject struct {
ID int `json:"id"`
Type string `json:"type"`
Info json.RawMessage `json:"info"`
}
type dumpNodeInfo struct {
Props json.RawMessage `json:"props"`
}
type dumpPortInfo struct {
Direction string `json:"direction"`
Props json.RawMessage `json:"props"`
}
// waitAndMatchPorts polls pw-dump until both the target node and our pw-cat
// node are present, then pairs target output ports to our input ports.
func waitAndMatchPorts(ctx context.Context, targetName, ourName string) (outIDs, inIDs []int, err error) {
for {
select {
case <-ctx.Done():
return nil, nil, ctx.Err()
default:
}
raw, dumpErr := exec.Command("pw-dump").Output()
if dumpErr != nil {
time.Sleep(100 * time.Millisecond)
continue
}
var objects []dumpObject
if err := json.Unmarshal(raw, &objects); err != nil {
time.Sleep(100 * time.Millisecond)
continue
}
nodeIDs := make(map[string]int)
for _, obj := range objects {
if obj.Type != "PipeWire:Interface:Node" {
continue
}
var info dumpNodeInfo
if json.Unmarshal(obj.Info, &info) != nil {
continue
}
var props map[string]any
if json.Unmarshal(info.Props, &props) != nil {
continue
}
if name, ok := props["node.name"].(string); ok {
nodeIDs[name] = obj.ID
}
}
targetID, targetOK := nodeIDs[targetName]
ourID, ourOK := nodeIDs[ourName]
if !targetOK || !ourOK {
time.Sleep(100 * time.Millisecond)
continue
}
var targetOuts, ourIns []portEntry
for _, obj := range objects {
if obj.Type != "PipeWire:Interface:Port" {
continue
}
var info dumpPortInfo
if json.Unmarshal(obj.Info, &info) != nil {
continue
}
var props map[string]any
if json.Unmarshal(info.Props, &props) != nil {
continue
}
nodeIDf, ok := props["node.id"].(float64)
if !ok {
continue
}
nodeID := int(nodeIDf)
ch, _ := props["audio.channel"].(string)
switch {
case nodeID == targetID && info.Direction == "output":
targetOuts = append(targetOuts, portEntry{obj.ID, ch})
case nodeID == ourID && info.Direction == "input":
ourIns = append(ourIns, portEntry{obj.ID, ch})
}
}
if len(targetOuts) == 0 || len(ourIns) == 0 {
time.Sleep(100 * time.Millisecond)
continue
}
pairs := matchPorts(targetOuts, ourIns)
if len(pairs) == 0 {
return nil, nil, fmt.Errorf("no matching ports between %q and %q", targetName, ourName)
}
for _, p := range pairs {
outIDs = append(outIDs, p[0])
inIDs = append(inIDs, p[1])
}
return outIDs, inIDs, nil
}
}
// matchPorts pairs output ports to input ports by audio.channel name,
// falling back to positional order when channel names are missing or unmatched.
func matchPorts(outs, ins []portEntry) [][2]int {
inByChannel := make(map[string]int, len(ins))
for _, p := range ins {
if p.channel != "" {
inByChannel[p.channel] = p.id
}
}
used := make(map[int]bool)
var result [][2]int
for _, o := range outs {
if inID, ok := inByChannel[o.channel]; ok && !used[inID] {
result = append(result, [2]int{o.id, inID})
used[inID] = true
}
}
if len(result) > 0 {
return result
}
for i := range min(len(outs), len(ins)) {
result = append(result, [2]int{outs[i].id, ins[i].id})
}
return result
}

211
live-server-spec.md Normal file
View File

@@ -0,0 +1,211 @@
# Live-Audio Sendspin Server — Design Spec
A Sendspin server that captures audio from any PipeWire source via `pw-cat` and streams it to a configurable set of Sendspin speaker clients. Controlled at runtime from Home Assistant over MQTT; configured (group definitions) over HTTP.
This server coexists with other Sendspin servers (e.g., Music Assistant). It claims and releases speakers using the protocol's multi-server arbitration described in [sendspin-spec.md](sendspin-spec.md).
## Goals
- Stream any local PipeWire source (line-in, USB capture, application output, virtual loopback) to a set of speakers in sync.
- Let Home Assistant pick which preset of speakers is active, start/stop playback, and set group volume — without HA needing to speak Sendspin.
- Keep group/preset configuration out of HA (HTTP/web UI for that).
- Cooperate cleanly with other Sendspin servers: claim speakers only when active, release them when not.
## Non-goals
- Multi-room presence-based muting. That belongs in the speaker client ([internal/pipewire/](internal/pipewire/)) — speaker mutes itself locally on MQTT command and reports state via `client/state`.
- Music library, playlists, metadata search. This server only ferries live PipeWire audio.
- Sendspin roles other than `player`. No metadata/artwork/visualizer/color generation.
## Components
```
┌──────────────────────────────────────┐
│ live-audio server │
HTTP (config) ◀────┤ │
│ ┌────────────┐ ┌──────────────┐ │
MQTT (control) ◀───┤ │ group mgr │───▶│ sendspin srv │──┼──▶ speakers
│ └────────────┘ └──────────────┘ │ (mDNS-discovered)
│ │ ▲ │
│ ▼ │ │
│ ┌──────────┐ ┌───────────┐ │
│ │ pw-cat │─PCM─▶│ encoder │ │
│ └──────────┘ └───────────┘ │
└──────────────────────────────────────┘
```
1. **Sendspin server core** — implements the Sendspin protocol (server-initiated mode), browses mDNS for `_sendspin._tcp`, holds WS connections, encodes audio per client, sends timestamped chunks.
2. **Audio source**`pw-cat --capture --target <node>` producing PCM to the encoder. One active capture at a time.
3. **Group manager** — owns the set of configured presets (which speakers, which PipeWire source). Knows which preset is "active" and drives connect/disconnect/start/stop transitions.
4. **HTTP server** — CRUD for presets, lookup endpoints for discovered speakers and available PipeWire sources, optional static web UI.
5. **MQTT bridge** — translates HA commands to group-manager actions; publishes state back as retained messages with HA MQTT discovery payloads.
## Concepts
### Preset
A preset is a named configuration that maps **one PipeWire source** to **a set of speaker clients**. Presets are user-defined and persisted to disk.
```json
{
"id": "kitchen-radio",
"name": "Kitchen Radio",
"source": "alsa_input.usb-AudioCapture.pro-input-0",
"members": ["speaker-living-room", "speaker-kitchen"]
}
```
- `id`: stable slug used in MQTT/HTTP. Lowercase, kebab-case.
- `members`: Sendspin `client_id` values, not friendly names.
### Active preset and playback state
Exactly **zero or one** preset is active at a time.
| State | Active preset | WS connections | pw-cat running | Streaming |
|------|---------------|----------------|----------------|-----------|
| `off` | none | closed | no | no |
| `paused` | one | open (claim held) | no | no (`stream/end` sent) |
| `playing` | one | open (claim held) | yes | yes (`stream/start` + audio chunks) |
Transitions are driven by MQTT commands and by configuration changes (e.g., removing a member from the active preset).
### Multi-server arbitration
Per [sendspin-spec.md §Multiple Servers](sendspin-spec.md):
- When entering `paused` or `playing`, the server connects (or reconnects) to each member with `connection_reason: 'playback'`. This wins against any other server holding the speaker.
- When leaving the active state (going to `off`, or removing a member), the server closes the WebSocket. The speaker becomes available for other servers' next claim attempt.
- While paused, the server keeps the WS open with `'playback'` reason — fast resume, but another server using `'playback'` (e.g., Music Assistant pressing play) can still preempt it. That's the correct cooperative behavior.
## MQTT interface
### Connection
- Broker URL, username, password supplied via CLI flags / env vars.
- Discovery topic prefix: `homeassistant` (configurable).
- Base topic: `sendspin/live` (configurable per server instance, so multiple live-audio servers can coexist).
- LWT: `sendspin/live/availability` = `offline` (retained), set to `online` after connect.
### State topics (retained, published by server)
| Topic | Payload | Notes |
|------|---------|-------|
| `sendspin/live/availability` | `online` \| `offline` | LWT-backed. |
| `sendspin/live/active_preset` | preset `id` or `""` | Empty string means `off`. |
| `sendspin/live/playback` | `playing` \| `paused` \| `off` | |
| `sendspin/live/volume` | `0``100` | Group volume; see [sendspin-spec.md §Setting group volume](sendspin-spec.md#switch-command-cycle). |
| `sendspin/live/presets` | JSON array of `{id,name}` | Updated when presets change. Lets HA refresh its select options. |
### Command topics (subscribed by server)
| Topic | Payload | Effect |
|------|---------|--------|
| `sendspin/live/active_preset/set` | preset `id` or `""` | Switch active preset. `""``off`. Implies a state transition; preserves last `playback` state when possible (see below). |
| `sendspin/live/playback/set` | `play` \| `pause` \| `stop` | `play`: requires an active preset; goes to `playing`. `pause`: keep claim, stop stream. `stop`: alias for setting `active_preset` to `""`. |
| `sendspin/live/volume/set` | `0``100` | Sets group volume on the active preset's members. No-op if `off`. |
### Active preset switching semantics
- `off` → preset X: claim members, remain in `paused` (don't auto-play; HA can issue `play` next). Rationale: avoid surprise audio when the user is just selecting a preset.
- preset X → preset Y (both non-empty): release X's exclusive members, claim Y's, carry over the playback state (`playing` stays `playing`, `paused` stays `paused`). Members present in both stay connected.
- preset X → `off`: send `stream/end` if playing, close all WS connections, go to `off`.
### HA MQTT discovery
On connect, publish discovery configs (retained) so HA auto-creates entities:
- A `select` entity bound to `sendspin/live/active_preset` / `…/set`, with options taken from the `presets` topic.
- A `switch` or `button` set for `playback/set` — typically a `media_player` is overkill; a simple `select` (`playing`/`paused`/`off`) or two `button`s (`play`, `pause`) plus current-state `sensor` is enough.
- A `number` entity for `volume`.
- A `binary_sensor` mirroring `availability`.
Discovery topics follow `<prefix>/<component>/sendspin_live/<object_id>/config`. Republish all discovery configs on every connect (idempotent, retained).
## HTTP interface
Listens on a configurable port (default `8080`). Serves both a REST API and an optional static web UI for editing presets.
### Endpoints
| Method | Path | Purpose |
|-------|------|---------|
| `GET` | `/api/presets` | List all presets. |
| `POST` | `/api/presets` | Create. Body: preset JSON without `id` (server generates) or with `id` (server validates uniqueness). |
| `GET` | `/api/presets/{id}` | Read. |
| `PUT` | `/api/presets/{id}` | Replace. If `id` is the active preset, apply changes live. |
| `DELETE` | `/api/presets/{id}` | Delete. If active, transitions server to `off` first. |
| `GET` | `/api/clients` | List Sendspin clients currently discovered via mDNS. Returns `[{client_id, name, connected, last_seen}]`. Lets the UI build a member picker. |
| `GET` | `/api/sources` | List PipeWire sources (wraps `pw-dump` like [internal/pipewire/nodes.go](internal/pipewire/nodes.go)). Returns `[{name, description}]`. |
| `GET` | `/api/state` | Current `{active_preset, playback, volume, members: [{client_id, connected, muted, volume}]}`. Read-only mirror of MQTT state plus per-member detail. |
| `GET` | `/` | Static web UI (optional, single-page). |
### Persistence
Presets stored as a single JSON file (default `~/.config/sendspin-live/presets.json`, overridable via `--config`). Write-on-change with atomic rename. No database.
### Auth
None by default — assumes trusted LAN. A future flag could add HTTP basic auth; out of scope for v1.
## Audio pipeline
- Capture: `pw-cat --capture --target <source> --rate 48000 --channels 2 --format s32 -` to stdout. Reuse [internal/pipewire/source.go](internal/pipewire/source.go).
- One capture process per active preset. Stop it on transition to `paused`/`off`. Restart on transition into `playing`.
- Encoding: per-client encoders (Opus by default, FLAC/PCM as per `client/hello` capabilities) — handled by the upstream `sendspin-go` library; this server just feeds PCM frames.
- Sample rate / channels: configurable via CLI flags, same as the existing simple server. v1 does not switch capture rate based on preset.
## Process model
Single Go binary. Goroutines for:
- mDNS browse loop.
- Sendspin server (HTTP upgrade + per-client goroutines, provided by `sendspin-go`).
- `pw-cat` reader, owned by the group manager and recreated per playback start.
- HTTP server.
- MQTT client (paho or similar) with reconnect.
Group manager is the single source of truth for state. All state transitions go through it (channel- or mutex-serialized) to keep MQTT, sendspin core, and pw-cat in agreement.
## CLI
```
sendspin-live-server [flags]
--port int Sendspin WS port (default 8927)
--name string advertised server name (default "Live Audio")
--http-port int HTTP/API port (default 8080)
--config string presets file path (default ~/.config/sendspin-live/presets.json)
--mqtt-url string e.g. tcp://broker.local:1883 (required to enable MQTT bridge)
--mqtt-username string
--mqtt-password string
--mqtt-base string base topic (default "sendspin/live")
--mqtt-discovery string HA discovery prefix (default "homeassistant")
--rate int capture sample rate (default 48000)
--channels int capture channels (default 2)
--log-level string debug|info|warn|error (default info)
```
`--mqtt-url` empty disables the MQTT bridge entirely; HTTP still works.
## Layout (proposed)
```
cmd/live-server/main.go # flags, wiring, signal handling
internal/live/
manager.go # group manager, state machine
preset.go # preset type + JSON persistence
http.go # REST handlers + static UI
mqtt.go # MQTT bridge + HA discovery
internal/pipewire/ # existing — source.go reused
web/ # static SPA assets (optional)
```
The existing simple [cmd/server/main.go](cmd/server/main.go) stays as-is for one-source-one-server use; the live server is a separate binary.
## Open questions
- **Per-preset capture format**: v1 keeps one global rate/channels. Worth revisiting if any source is mono or 44.1k native.
- **Preset transitions and group identity**: should two presets sharing the same members + source map to the same Sendspin `group_id`, or always get a fresh group on each activation? Fresh group is simpler and avoids stale `group_name` confusion.
- **Per-member volume in MQTT**: v1 exposes group volume only. Individual speakers are muted/volumed via their own MQTT topics (separate concern on the speaker client).
- **Speaker discovery while inactive**: server should still browse mDNS in `off` state so `/api/clients` is useful for editing presets. Browsing != connecting.

BIN
out.raw Normal file

Binary file not shown.

752
sendspin-spec.md Normal file
View File

@@ -0,0 +1,752 @@
# The Sendspin Protocol
_This is raw, unfiltered and experimental._
Sendspin is a multi-room music experience protocol. The goal of the protocol is to orchestrate all devices that make up the music listening experience. This includes outputting audio on multiple speakers simultaneously, screens and lights visualizing the audio or album art, and wall tablets providing media controls.
## Definitions
- **Sendspin Server** - orchestrates all devices, generates audio streams, manages players and clients, provides metadata
- **Sendspin Client** - a client that can play audio, visualize audio, display metadata, display colors, or provide music controls. Has different possible roles (player, metadata, controller, artwork, visualizer, color). Every client has a unique identifier
- **Player** - receives audio and plays it in sync. Has its own volume and mute state and preferred format settings
- **Controller** - controls the Sendspin group this client is part of
- **Metadata** - displays text metadata (title, artist, album, etc.)
- **Artwork** - displays artwork images. Has preferred format for images
- **Visualizer** - visualizes music. Has preferred format for audio features
- **Color** - receives colors derived from the current audio
- **Sendspin Group** - a group of clients. Each client belongs to exactly one group, and every group has at least one client. Every group has a unique identifier. Each group has the following states: list of member clients, volume, mute, and playback state
- **Sendspin Stream** - client-specific details on how the server is formatting and sending binary data. Each role's stream is managed separately. Each client receives its own independently encoded stream based on its capabilities and preferences. For players, the server sends audio chunks as far ahead as the client's buffer capacity allows. For artwork clients, the server sends album artwork and other visual images through the stream
## Role Versioning
Roles define what capabilities and responsibilities a client has. All roles use explicit versioning with the `@` character: `<role>@<version>` (e.g., `player@v1`, `controller@v1`).
This specification defines the following roles: [`player`](#player-messages), [`controller`](#controller-messages), [`metadata`](#metadata-messages), [`artwork`](#artwork-messages), [`visualizer`](#visualizer-messages), [`color`](#color-messages). All servers must implement all versions of these roles described in this specification.
All role names and versions not starting with `_` are reserved for future revisions of this specification.
### Priority and Activation
Clients list roles in `supported_roles` in priority order (most preferred first). If a client supports multiple versions of a role, all should be listed: `["player@v2", "player@v1"]`.
The server activates one version per role family (e.g., one `player@vN`, one `controller@vN`)—the first match it implements from the client's list. The server reports activated roles in `active_roles`.
Message object keys (e.g., `player?`, `controller?`) use unversioned role names. The server determines the appropriate version from the client's `active_roles`.
### Detecting Outdated Servers
Servers should track when clients request roles or role versions they don't implement (excluding those starting with `_`). This indicates the client supports a newer version of the specification and the server needs to be updated.
### Application-Specific Roles
Custom roles outside the specification start with `_` (e.g., `_myapp_controller`, `_custom_display`). Application-specific roles can also be versioned: `_myapp_visualizer@v2`.
## Establishing a Connection
Sendspin has two standard ways to establish connections: Server and Client initiated. Server Initiated connections are recommended as they provide standardized multi-server behavior, but require mDNS which may not be available in all environments.
Sendspin Servers must support both methods described below.
### Server Initiated Connections
Clients announce their presence via mDNS using:
- Service type: `_sendspin._tcp.local.`
- Port: The port the Sendspin client is listening on (recommended: `8928`)
- TXT record: `path` key specifying the WebSocket endpoint (recommended: `/sendspin`)
- TXT record: `name` key specifying the friendly name of the player (optional)
The server discovers available clients through mDNS and connects to each client via WebSocket using the advertised address and path.
**Note:** Do not manually connect to servers if you are advertising `_sendspin._tcp`.
#### Multiple Servers
In environments with multiple Sendspin servers, servers may need to reconnect to clients when starting playback to reclaim them. The [`server/hello`](#server--client-serverhello) message includes a `connection_reason` field indicating whether the server is connecting for general availability (`'discovery'`) or for active/upcoming playback (`'playback'`).
Clients can only be connected to one server at a time. Clients must persistently store the `server_id` of the server that most recently had `playback_state: 'playing'` (the "last played server").
When a second server connects, clients must:
1. **Accept incoming connections**: Complete the handshake (send [`client/hello`](#client--server-clienthello), receive [`server/hello`](#server--client-serverhello)) with the new server before making any decisions.
2. **Decide which server to keep**:
- If the new server's `connection_reason` is `'playback'` → switch to new server
- If the new server's `connection_reason` is `'discovery'` and the existing server connected with `'playback'` → keep existing server
- If both servers have `connection_reason: 'discovery'`:
- Prefer the server matching the stored last played server
- If neither matches (or no history), keep the existing server
3. **Disconnect**: Send [`client/goodbye`](#client--server-clientgoodbye) with reason `'another_server'` to the server being disconnected, then close the connection.
### Client Initiated Connections
If clients prefer to initiate the connection instead of waiting for the server to connect, the server must be discoverable via mDNS using:
- Service type: `_sendspin-server._tcp.local.`
- Port: The port the Sendspin server is listening on (recommended: `8927`)
- TXT record: `path` key specifying the WebSocket endpoint (recommended: `/sendspin`)
- TXT record: `name` key specifying the friendly name of the server (optional)
Clients discover the server through mDNS and initiate a WebSocket connection using the advertised address and path.
**Note:** Do not advertise `_sendspin._tcp` if the client plans to initiate the connection.
#### Multiple Servers
Unlike server-initiated connections, servers cannot reclaim clients by reconnecting. How clients handle multiple discovered servers, server selection, and switching is implementation-defined.
**Note:** After this point, Sendspin works independently of how the connection was established. The Sendspin client is always the consumer of data like audio or metadata, regardless of who initiated the connection.
While custom connection methods are possible for specialized use cases (like remotely accessible web-browsers, mobile apps), most clients should use one of the two standardized methods above if possible.
## Communication
Once the connection is established, Client and Server are going to talk.
The first message must always be a `client/hello` message from the client to the server.
Once the server receives this message, it responds with a `server/hello` message. Before this handshake is complete, no other messages should be sent.
WebSocket text messages are used to send JSON payloads.
**Note:** In field definitions, `?` indicates an optional field (e.g., `field?`: type means the field may be omitted).
All messages have a `type` field identifying the message and a `payload` object containing message-specific data. The payload structure varies by message type and is detailed in each message section below.
Message format example:
```json
{
"type": "stream/start",
"payload": {
"player": {
"codec": "opus",
"sample_rate": 48000,
"channels": 2,
"bit_depth": 16
},
"artwork": {
"channels": [
{
"source": "album",
"format": "jpeg",
"width": 800,
"height": 800
}
]
}
}
}
```
WebSocket binary messages are used to send audio chunks, media art, and visualization data. The first byte is a uint8 representing the message type.
### Binary Message ID Structure
Binary message IDs typically use **bits 7-2** for role type and **bits 1-0** for message slot, allocating 4 IDs per role. Roles with expanded allocations use **bits 2-0** for message slot (8 IDs).
**Role assignments:**
- `000000xx` (0-3): Reserved for future use
- `000001xx` (4-7): Player role
- `000010xx` (8-11): Artwork role
- `000011xx` (12-15): Reserved for a future role
- `00010xxx` (16-23): Visualizer role
- Roles 6-47 (IDs 24-191): Reserved for future roles
- Roles 48-63 (IDs 192-255): Available for use by [application-specific roles](#application-specific-roles)
**Message slots:**
- Slot 0: `xxxxxx00`
- Slot 1: `xxxxxx01`
- Slot 2: `xxxxxx10`
- Slot 3: `xxxxxx11`
Roles with expanded allocations have slots 0-7.
**Note:** Role versions share the same binary message IDs (e.g., `player@v1` and `player@v2` both use IDs 4-7).
## Clock Synchronization
Clients continuously send `client/time` messages to maintain an accurate offset from the server's clock. The frequency of these messages is determined by the client based on network conditions and clock stability.
Binary audio messages contain timestamps in the server's time domain indicating when the audio should be played. Clients must use the [time-filter](https://github.com/Sendspin-Protocol/time-filter) algorithm to translate server timestamps to their local clock for synchronized playback. The time filter is a two-dimensional Kalman filter that tracks both clock offset and drift. See the [time-filter](https://github.com/Sendspin-Protocol/time-filter) repository for a C++ reference implementation and [aiosendspin](https://github.com/Sendspin-Protocol/aiosendspin/blob/main/aiosendspin/client/time_sync.py) for a Python implementation.
Each [`server/time`](#server--client-servertime) response provides the four timestamps needed by the filter: the client's transmitted timestamp, the server's received timestamp, the server's transmitted timestamp, and the client's receive time (captured locally when the response arrives). Clients feed these into the time filter via its `update` method and use its `compute_client_time` method to convert server timestamps to local clock values for playback scheduling.
## Playback Synchronization
- Each client is responsible for maintaining synchronization with the server's timestamps
- Clients maintain accurate sync by adding or removing samples using interpolation to compensate for clock drift
- When a client cannot maintain sync (e.g., buffer underrun), it should send `state: 'error'` via [`client/state`](#client--server-clientstate), mute its audio output, and continue buffering until it can resume synchronized playback, at which point it should send `state: 'synchronized'`
- The server is unaware of individual client synchronization accuracy - it simply broadcasts timestamped audio
- The server sends audio to late-joining clients with future timestamps only, allowing them to buffer and start playback in sync with existing clients
- Audio chunks may arrive with timestamps in the past due to network delays or buffering; clients should drop these late chunks to maintain sync
- Clients subtract their [`static_delay_ms`](#client--server-clientstate-player-object) from server timestamps before scheduling playback
- Servers factor in each client's `static_delay_ms` when calculating how far ahead to send audio, keeping effective buffer headroom constant
```mermaid
sequenceDiagram
participant Client
participant Server
Note over Client,Server: WebSocket connection established
Note over Client,Server: Text messages = JSON payloads, Binary messages = Audio/Art/Visualization
Client->>Server: client/hello (roles and capabilities)
Server->>Client: server/hello (server info, connection_reason)
Client->>Server: client/state (state: synchronized)
alt Player role
Client->>Server: client/state (player: volume, muted)
end
loop Continuous clock sync
Client->>Server: client/time (client clock)
Server->>Client: server/time (timing + offset info)
end
alt Stream starts
Server->>Client: stream/start (codec, format details)
end
Server->>Client: group/update (playback_state, group_id, group_name)
Server->>Client: server/state (metadata, controller, color)
loop During playback
alt Player role
Server->>Client: binary Type 4 (audio chunks with timestamps)
end
alt Artwork role
Server->>Client: binary Types 8-11 (artwork channels 0-3)
end
alt Visualizer role
Server->>Client: binary Type 16 (visualization data)
end
end
alt Player requests format change
Client->>Server: stream/request-format (codec, sample_rate, etc)
Server->>Client: stream/start (player: new format)
end
alt Seek operation
Server->>Client: stream/clear (roles: [player, visualizer])
end
alt Controller role
Client->>Server: client/command (controller: play/pause/volume/switch/etc)
end
alt State changes
Client->>Server: client/state (state and/or player changes)
end
alt Server commands player
Server->>Client: server/command (player: volume, mute)
end
Server->>Client: stream/end (ends all role streams)
alt Graceful disconnect
Client->>Server: client/goodbye (reason)
Note over Client,Server: Server initiates disconnect
end
```
## Core messages
This section describes the fundamental messages that establish communication between clients and the server. These messages handle initial handshakes, ongoing clock synchronization, stream lifecycle management, and role-based state updates and commands.
Every Sendspin client and server must implement all messages in this section regardless of their specific roles. Role-specific object details are documented in their respective role sections and need to be implemented only if the client supports that role.
### Client → Server: `client/hello`
First message sent by the client after establishing the WebSocket connection. Contains information about the client's capabilities and roles.
This message will be followed by a [`server/hello`](#server--client-serverhello) message from the server.
Players that can output audio should have the role `player`.
- `client_id`: string - uniquely identifies the client for groups and de-duplication. Should remain persistent across reconnections so servers can associate clients with previous sessions (e.g., remembering group membership, settings, playback queue)
- `name`: string - friendly name of the client
- `device_info?`: object - optional information about the device
- `product_name?`: string - device model/product name
- `manufacturer?`: string - device manufacturer name
- `software_version?`: string - software version of the client (not the Sendspin version)
- `version`: integer (must be `1`) - version of the core message format that the Sendspin client implements (independent of role versions)
- `supported_roles`: string[] - versioned roles supported by the client (e.g., `player@v1`, `controller@v1`). Defined versioned roles are:
- `player@v1` - outputs audio
- `controller@v1` - controls the current Sendspin group
- `metadata@v1` - displays text metadata describing the currently playing audio
- `artwork@v1` - displays artwork images
- `visualizer@v1` - visualizes audio
- `color@v1` - receives colors derived from the current audio
- `player@v1_support?`: object - only if `player@v1` is listed ([see player@v1 support object details](#client--server-clienthello-playerv1-support-object))
- `artwork@v1_support?`: object - only if `artwork@v1` is listed ([see artwork@v1 support object details](#client--server-clienthello-artworkv1-support-object))
- `visualizer@v1_support?`: object - only if `visualizer@v1` is listed ([see visualizer@v1 support object details](#client--server-clienthello-visualizerv1-support-object))
**Note:** Each role version may have its own support object (e.g., `player@v1_support`, `player@v2_support`). Application-specific roles or role versions follow the same pattern (e.g., `_myapp_display@v1_support`, `player@_experimental_support`).
### Client → Server: `client/time`
Sends current internal clock timestamp (in microseconds) to the server.
Once received, the server responds with a [`server/time`](#server--client-servertime) message containing timing information to establish clock offsets.
- `client_transmitted`: integer - client's internal clock timestamp in microseconds
### Server → Client: `server/hello`
Response to the [`client/hello`](#client--server-clienthello) message with information about the server.
Only after receiving this message should the client send any other messages (including [`client/time`](#client--server-clienttime) and the initial [`client/state`](#client--server-clientstate) message if the client has roles that require state updates).
- `server_id`: string - identifier of the server
- `name`: string - friendly name of the server
- `version`: integer (must be `1`) - version of the core message format that the server implements (independent of role versions)
- `active_roles`: string[] - versioned roles that are active for this client (e.g., `player@v1`, `controller@v1`)
- `connection_reason`: 'discovery' | 'playback' - only used for [server-initiated connections](#multiple-servers)
- `discovery` - server is connecting for general availability (e.g., initial discovery, reconnection after connection loss)
- `playback` - server needs client for active or upcoming playback
**Note:** Servers will always activate the client's [preferred](#priority-and-activation) version of each role. Checking `active_roles` is only necessary to detect outdated servers or confirm activation of [application-specific roles](#application-specific-roles).
### Server → Client: `server/time`
Response to the [`client/time`](#client--server-clienttime) message with timestamps to establish clock offsets.
For synchronization, all timing is relative to the server's monotonic clock. These timestamps have microsecond precision and are not necessarily based on epoch time.
- `client_transmitted`: integer - client's internal clock timestamp received in the `client/time` message
- `server_received`: integer - timestamp that the server received the `client/time` message in microseconds
- `server_transmitted`: integer - timestamp that the server transmitted this message in microseconds
### Client → Server: `client/state`
Client sends state updates to the server. Contains client-level state and role-specific state objects.
Must be sent immediately after receiving [`server/hello`](#server--client-serverhello), and whenever any state changes thereafter.
For the initial message, include all state fields. For subsequent updates, only include fields that have changed. The server will merge these updates into existing state.
- `state`: 'synchronized' | 'error' | 'external_source' - operational state of the client
- `'synchronized'` - client is operational and synchronized with server timestamps
- `'error'` - client has a problem preventing normal operation (unable to keep up, clock sync issues, etc.)
- `'external_source'` - client is in use by an external system and is not currently participating in Sendspin playback with this server. See [External Source Handling](#external-source-handling)
- `player?`: object - only if client has `player` role ([see player state object details](#client--server-clientstate-player-object))
[Application-specific roles](#application-specific-roles) may also include objects in this message (keys starting with `_`).
### External Source Handling
When a client sets `state: 'external_source'`, it indicates the client's output is in use by an external system (e.g., a different audio source, HDMI input, or local media playback) and is not currently participating in Sendspin playback with this server.
#### Server behavior when `state` changes to `'external_source'`:
If the client is in a multi-client group:
1. Remember the client's current group as its "previous group" (see [switch command cycle](#switch-command-cycle))
2. Move the client to a new solo group (stopped)
- Send [`group/update`](#server--client-groupupdate) with the new group information
- Send [`stream/end`](#server--client-streamend) for all active streams
If the client is already in a solo group:
- Stop playback and send [`stream/end`](#server--client-streamend) for all active streams
### Client → Server: `client/command`
Client sends commands to the server. Contains command objects based on the client's supported roles.
- `controller?`: object - only if client has `controller` role ([see controller command object details](#client--server-clientcommand-controller-object))
[Application-specific roles](#application-specific-roles) may also include objects in this message (keys starting with `_`).
### Server → Client: `server/state`
Server sends state updates to the client. Contains role-specific state objects.
Only include fields that have changed. The client will merge these updates into existing state. Fields set to `null` should be cleared from the client's state.
- `metadata?`: object - only sent to clients with `metadata` role ([see metadata state object details](#server--client-serverstate-metadata-object))
- `controller?`: object - only sent to clients with `controller` role ([see controller state object details](#server--client-serverstate-controller-object))
- `color?`: object - only sent to clients with `color` role ([see color state object details](#server--client-serverstate-color-object))
[Application-specific roles](#application-specific-roles) may also include objects in this message (keys starting with `_`).
### Server → Client: `server/command`
Server sends commands to the client. Contains role-specific command objects.
- `player?`: object - only sent to clients with `player` role ([see player command object details](#server--client-servercommand-player-object))
[Application-specific roles](#application-specific-roles) may also include objects in this message (keys starting with `_`).
### Server → Client: `stream/start`
Starts a stream for one or more roles. If sent for a role that already has an active stream, updates the stream configuration without clearing buffers.
- `player?`: object - only sent to clients with the `player` role ([see player object details](#server--client-streamstart-player-object))
- `artwork?`: object - only sent to clients with the `artwork` role ([see artwork object details](#server--client-streamstart-artwork-object))
- `visualizer?`: object - only sent to clients with the `visualizer` role ([see visualizer object details](#server--client-streamstart-visualizer-object))
[Application-specific roles](#application-specific-roles) may also include objects in this message (keys starting with `_`).
### Server → Client: `stream/clear`
Instructs clients to clear buffers without ending the stream. Used for seek operations.
- `roles?`: string[] - which roles to clear: '[player](#server--client-streamclear-player)', '[visualizer](#server--client-streamclear-visualizer)', or both. If omitted, clears both roles
[Application-specific roles](#application-specific-roles) may also be included in this array (names starting with `_`).
### Client → Server: `stream/request-format`
Request different stream format (upgrade or downgrade). Available for clients with the `player` or `artwork` role.
- `player?`: object - only for clients with the `player` role ([see player object details](#client--server-streamrequest-format-player-object))
- `artwork?`: object - only for clients with the `artwork` role ([see artwork object details](#client--server-streamrequest-format-artwork-object))
[Application-specific roles](#application-specific-roles) may also include objects in this message (keys starting with `_`).
Response: [`stream/start`](#server--client-streamstart) for the requested role(s) with the new format.
**Note:** Clients should use this message to adapt to changing network conditions, CPU constraints, or display requirements. The server maintains separate encoding for each client, allowing heterogeneous device capabilities within the same group.
### Server → Client: `stream/end`
Ends the stream for one or more roles. When received, clients should stop output and clear buffers for the specified roles.
- `roles?`: string[] - roles to end streams for ('player', 'artwork', 'visualizer'). If omitted, ends all active streams
[Application-specific roles](#application-specific-roles) may also be included in this array (names starting with `_`).
### Server → Client: `group/update`
State update of the group this client is part of.
Contains delta updates with only the changed fields. The client should merge these updates into existing state. Fields set to `null` should be cleared from the client's state.
- `playback_state?`: 'playing' | 'stopped' - playback state of the group
- `group_id?`: string - group identifier
- `group_name?`: string - friendly name of the group
### Client → Server: `client/goodbye`
Sent by the client before gracefully closing the connection. This allows the client to inform the server why it is disconnecting.
Upon receiving this message, the server should initiate the disconnect.
- `reason`: 'another_server' | 'shutdown' | 'restart' | 'user_request'
- `another_server` - client is switching to a different Sendspin server. Server should not auto-reconnect but should show the client as available for future playback
- `shutdown` - client is shutting down. Server should not auto-reconnect
- `restart` - client is restarting and will reconnect. Server should auto-reconnect
- `user_request` - user explicitly requested to disconnect from this server. Server should not auto-reconnect
**Note:** Clients may close the connection without sending this message (e.g., crash, network loss), or immediately after sending `client/goodbye` without waiting for the server to disconnect. When a client disconnects without sending `client/goodbye`, servers should assume the disconnect reason is `restart` and attempt to auto-reconnect.
## Player messages
This section describes messages specific to clients with the `player` role, which handle audio output and synchronized playback. Player clients receive timestamped audio data, manage their own volume and mute state, and can request different audio formats based on their capabilities and current conditions.
**Note:** Volume values (0-100) represent perceived loudness, not linear amplitude (e.g., volume 50 should be perceived as half as loud as volume 100). Players must convert these values to appropriate amplitude for their audio hardware.
### Client → Server: `client/hello` player@v1 support object
The `player@v1_support` object in [`client/hello`](#client--server-clienthello) has this structure:
- `player@v1_support`: object
- `supported_formats`: object[] - list of supported audio formats in priority order (first is preferred)
- `codec`: 'opus' | 'flac' | 'pcm' - codec identifier
- `channels`: integer - supported number of channels (e.g., 1 = mono, 2 = stereo)
- `sample_rate`: integer - sample rate in Hz (e.g., 44100)
- `bit_depth`: integer - bit depth for this format (e.g., 16, 24)
- `buffer_capacity`: integer - max size in bytes of compressed audio messages in the buffer that are yet to be played
- `supported_commands`: string[] - subset of: 'volume', 'mute'
**Note:** Servers must support all audio codecs: 'opus', 'flac', and 'pcm'.
**PCM Encoding Convention:** For the `pcm` codec, samples are encoded as little-endian signed integers (two's complement). 24-bit samples are packed as 3 bytes per sample.
### Client → Server: `client/state` player object
The `player` object in [`client/state`](#client--server-clientstate) has this structure:
Informs the server of player-specific state changes. Only for clients with the `player` role.
State updates must be sent whenever any state changes, including when the volume was changed through a `server/command` or via device controls.
- `player`: object
- `volume?`: integer - range 0-100, must be included if 'volume' is in `supported_commands` from [`player@v1_support`](#client--server-clienthello-playerv1-support-object)
- `muted?`: boolean - mute state, must be included if 'mute' is in `supported_commands` from [`player@v1_support`](#client--server-clienthello-playerv1-support-object)
- `static_delay_ms`: integer - static delay in milliseconds (0-5000), always required for players
- `supported_commands?`: string[] - subset of: 'set_static_delay'
**Static delay:** The default is 0, meaning audio exits the device's audio port at the timestamp. `static_delay_ms` compensates for additional delay beyond the port (external speakers, amplifiers). Negative values are not supported and should never be required for any compliant implementation. Clients must persist `static_delay_ms` locally across reboots and server reconnections. Clients may update `static_delay_ms` and `supported_commands` when audio output changes (e.g., external speaker connected), persisting separate delays per output.
### Client → Server: `stream/request-format` player object
The `player` object in [`stream/request-format`](#client--server-streamrequest-format) has this structure:
- `player`: object
- `codec?`: 'opus' | 'flac' | 'pcm' - requested codec identifier
- `channels?`: integer - requested number of channels (e.g., 1 = mono, 2 = stereo)
- `sample_rate?`: integer - requested sample rate in Hz (e.g., 44100, 48000)
- `bit_depth?`: integer - requested bit depth (e.g., 16, 24)
Response: [`stream/start`](#server--client-streamstart) with the new format.
**Note:** Clients should use this message to adapt to changing network conditions or CPU constraints. The server maintains separate encoding for each client, allowing heterogeneous device capabilities within the same group.
### Server → Client: `server/command` player object
The `player` object in [`server/command`](#server--client-servercommand) has this structure:
Request the player to perform an action, e.g., change volume or mute state.
- `player`: object
- `command`: 'volume' | 'mute' | 'set_static_delay' - must be listed in `supported_commands` from [`player@v1_support`](#client--server-clienthello-playerv1-support-object) or from [`client/state`](#client--server-clientstate-player-object); unlisted commands are ignored by the client
- `volume?`: integer - volume range 0-100, only set if `command` is `volume`
- `mute?`: boolean - true to mute, false to unmute, only set if `command` is `mute`
- `static_delay_ms?`: integer - delay in milliseconds (0-5000), only set if `command` is `set_static_delay`
### Server → Client: `stream/start` player object
The `player` object in [`stream/start`](#server--client-streamstart) has this structure:
- `player`: object
- `codec`: string - codec to be used
- `sample_rate`: integer - sample rate to be used
- `channels`: integer - channels to be used
- `bit_depth`: integer - bit depth to be used
- `codec_header?`: string - Base64 encoded codec header (if necessary; e.g., FLAC)
### Server → Client: `stream/clear` player
When [`stream/clear`](#server--client-streamclear) includes the player role, clients should clear all buffered audio chunks and continue with chunks received after this message.
### Server → Client: Audio Chunks (Binary)
Binary messages should be rejected if there is no active stream.
- Byte 0: message type `4` (uint8)
- Bytes 1-8: timestamp (big-endian int64) - server clock time in microseconds when the first sample should be output
- Rest of bytes: encoded audio frame
The timestamp indicates when the first audio sample in this chunk should be output. Clients must translate this server timestamp to their local clock using the offset computed from clock synchronization, subtracting their [`static_delay_ms`](#client--server-clientstate-player-object) from the timestamp. Clients should compensate for any known processing delays (e.g., DAC latency, audio buffer delays, amplifier delays) by accounting for these delays when submitting audio to the hardware.
## Controller messages
This section describes messages specific to clients with the `controller` role, which enables the client to control the Sendspin group this client is part of, and switch between groups.
Every client which lists the `controller` role in the `supported_roles` of the `client/hello` message needs to implement all messages in this section.
### Client → Server: `client/command` controller object
The `controller` object in [`client/command`](#client--server-clientcommand) has this structure:
Control the group that's playing and switch groups. Only valid from clients with the `controller` role.
- `controller`: object
- `command`: 'play' | 'pause' | 'stop' | 'next' | 'previous' | 'volume' | 'mute' | 'repeat_off' | 'repeat_one' | 'repeat_all' | 'shuffle' | 'unshuffle' | 'switch' - should be one of the values listed in `supported_commands` from the [`server/state`](#server--client-serverstate-controller-object) `controller` object. Commands not in `supported_commands` are ignored by the server
- `volume?`: integer - volume range 0-100, only set if `command` is `volume`
- `mute?`: boolean - true to mute, false to unmute, only set if `command` is `mute`
#### Command behaviour
- 'play' - resume playback from current position. If nothing is currently playing, the server must try to resume the group's last playing media. This history should persist across server and client reboots
- 'pause' - pause playback at current position
- 'stop' - stop playback and reset position to beginning
- 'next' - skip to next track, chapter, etc.
- 'previous' - skip to previous track, chapter, restart current, etc.
- 'volume' - set group volume (requires `volume` parameter)
- 'mute' - set group mute state (requires `mute` parameter)
- 'repeat_off' - disable repeat mode
- 'repeat_one' - repeat the current track continuously
- 'repeat_all' - repeat all tracks continuously
- 'shuffle' - randomize playback order
- 'unshuffle' - restore original playback order
- 'switch' - move this client to the next group in a predefined cycle as described [below](#switch-command-cycle)
**Setting group volume:** When setting group volume via the 'volume' command, the server applies the following algorithm to preserve relative volume levels while achieving the requested volume as closely as player boundaries allow:
1. Calculate the delta: `delta = requested_volume - current_group_volume` (where current group volume is the average of all player volumes)
2. Apply the delta to each player's volume
3. Clamp any player volumes that exceed boundaries (0-100%)
4. If any players were clamped:
- Calculate the lost delta: `sum of (proposed_volume - clamped_volume)` for all clamped players
- Divide the lost delta equally among non-clamped players
- Repeat steps 1-4 until either:
- All delta has been successfully applied, or
- All players are clamped at their volume boundaries
This ensures that when setting group volume to 100%, all players will reach 100% if possible, and the final group volume matches the requested volume as closely as player boundaries allow.
**Setting group mute:** When setting group mute via the 'mute' command, the server applies the mute state to all players in the group.
#### Switch command cycle
**Previous group priority:** If the client is still in the solo group from its `'external_source'` transition, the `switch` command prioritizes rejoining the previous group.
For clients **with** the `player` role, the cycle includes:
1. Multi-client groups that are currently playing
2. Single-client groups (other players playing alone)
3. A solo group containing only this client
For clients **without** the `player` role, the cycle includes:
1. Multi-client groups that are currently playing
2. Single-client groups (other players playing alone)
### Server → Client: `server/state` controller object
The `controller` object in [`server/state`](#server--client-serverstate) has this structure:
- `controller`: object
- `supported_commands`: string[] - subset of: 'play' | 'pause' | 'stop' | 'next' | 'previous' | 'volume' | 'mute' | 'repeat_off' | 'repeat_one' | 'repeat_all' | 'shuffle' | 'unshuffle' | 'switch'
- `volume`: integer - volume of the whole group, range 0-100
- `muted`: boolean - mute state of the whole group
- `repeat`: 'off' | 'one' | 'all' - repeat mode: 'off' = no repeat, 'one' = repeat current track, 'all' = repeat all tracks (in the queue, playlist, etc.)
- `shuffle`: boolean - shuffle mode enabled/disabled
**Reading group volume:** Group volume is calculated as the average of all player volumes in the group.
**Reading group mute:** Group mute is `true` only when all players in the group are muted. If some players are muted and others are not, group mute is `false`.
## Metadata messages
This section describes messages specific to clients with the `metadata` role, which handle display of track information and playback progress. Metadata clients receive state updates with track details.
### Server → Client: `server/state` metadata object
The `metadata` object in [`server/state`](#server--client-serverstate) has this structure:
- `metadata`: object
- `timestamp`: integer - server clock time in microseconds for when this metadata is valid
- `title?`: string | null - track title
- `artist?`: string | null - primary artist(s)
- `album_artist?`: string | null - album artist(s)
- `album?`: string | null - name of the album or release that this track belongs to
- `artwork_url?`: string | null - URL to artwork image. Useful for clients that want to forward metadata to external systems or for powerful clients that can fetch and process images themselves
- `year?`: integer | null - release year in YYYY format
- `track?`: integer | null - track number on the album (1-indexed), null if unknown or not applicable
- `progress?`: object | null - playback progress information. The server must send this object whenever playback state changes (play, pause, resume, seek, playback speed change)
- `track_progress`: integer - current playback position in milliseconds since start of track
- `track_duration`: integer - total track length in milliseconds, 0 for unlimited/unknown duration (e.g., live radio streams)
- `playback_speed`: integer - playback speed multiplier * 1000 (e.g., 1000 = normal speed, 1500 = 1.5x speed, 500 = 0.5x speed, 0 = paused)
#### Calculating current track position
Clients can calculate the current track position at any time using the `timestamp` and `progress` values from the last metadata message that included the `progress` object:
```python
calculated_progress = metadata.progress.track_progress + (current_time - metadata.timestamp) * metadata.progress.playback_speed / 1000000
if metadata.progress.track_duration != 0:
current_track_progress_ms = max(min(calculated_progress, metadata.progress.track_duration), 0)
else:
current_track_progress_ms = max(calculated_progress, 0)
```
## Artwork messages
This section describes messages specific to clients with the `artwork` role, which handle display of artwork images. Artwork clients receive images in their preferred format and resolution.
**Channels:** Artwork clients can support 1-4 independent channels, allowing them to display multiple related images. For example, a device could display album artwork on one channel while simultaneously showing artist photos or background images on other channels. Each channel operates independently with its own format, resolution, and source type (album or artist artwork).
### Client → Server: `client/hello` artwork@v1 support object
The `artwork@v1_support` object in [`client/hello`](#client--server-clienthello) has this structure:
- `artwork@v1_support`: object
- `channels`: object[] - list of supported artwork channels (length 1-4), array index is the channel number
- `source`: 'album' | 'artist' | 'none' - artwork source type
- `format`: 'jpeg' | 'png' | 'bmp' - image format identifier
- `media_width`: integer - max width in pixels
- `media_height`: integer - max height in pixels
**Note:** The server will scale images to fit within the specified dimensions while preserving aspect ratio. Clients can support 1-4 independent artwork channels depending on their display capabilities. The channel number is determined by array position: `channels[0]` is channel 0 (binary message type 8), `channels[1]` is channel 1 (binary message type 9), etc.
**None source:** If a channel has `source` set to `none`, the server will not send any artwork data for that channel. This allows clients to disable and enable specific channels on the fly through [`stream/request-format`](#client--server-streamrequest-format-artwork-object) without needing to re-establish the WebSocket connection (useful for dynamic display layouts).
**Note:** Servers must support all image formats: 'jpeg', 'png', and 'bmp'.
### Client → Server: `stream/request-format` artwork object
The `artwork` object in [`stream/request-format`](#client--server-streamrequest-format) has this structure:
Request the server to change the artwork format for a specific channel. The client can send multiple `stream/request-format` messages to change formats on different channels.
After receiving this message, the server responds with [`stream/start`](#server--client-streamstart) for the artwork role with the new format, followed by immediate artwork updates through binary messages.
- `artwork`: object
- `channel`: integer - channel number (0-3) corresponding to the channel index declared in the artwork [`client/hello`](#client--server-clienthello-artworkv1-support-object)
- `source?`: 'album' | 'artist' | 'none' - artwork source type
- `format?`: 'jpeg' | 'png' | 'bmp' - requested image format identifier
- `media_width?`: integer - requested max width in pixels
- `media_height?`: integer - requested max height in pixels
### Server → Client: `stream/start` artwork object
The `artwork` object in [`stream/start`](#server--client-streamstart) has this structure:
- `artwork`: object
- `channels`: object[] - configuration for each active artwork channel, array index is the channel number
- `source`: 'album' | 'artist' | 'none' - artwork source type
- `format`: 'jpeg' | 'png' | 'bmp' - format of the encoded image
- `width`: integer - width in pixels of the encoded image
- `height`: integer - height in pixels of the encoded image
### Server → Client: Artwork (Binary)
Binary messages should be rejected if there is no active stream.
- Byte 0: message type `8`-`11` (uint8) - corresponds to artwork channel 0-3 respectively
- Bytes 1-8: timestamp (big-endian int64) - server clock time in microseconds when the image should be displayed by the device
- Rest of bytes: encoded image
The message type determines which artwork channel this image is for:
- Type `8`: Channel 0 (Artwork role, slot 0)
- Type `9`: Channel 1 (Artwork role, slot 1)
- Type `10`: Channel 2 (Artwork role, slot 2)
- Type `11`: Channel 3 (Artwork role, slot 3)
The timestamp indicates when this artwork should be displayed. Clients must translate this server timestamp to their local clock using the offset computed from clock synchronization.
**Clearing artwork:** To clear the currently displayed artwork on a specific channel, the server sends an empty binary message (only the message type byte and timestamp, with no image data) for that channel.
## Visualizer messages
This section describes messages specific to clients with the `visualizer` role, which create visual representations of the audio being played. Visualizer clients receive audio analysis data like FFT information that corresponds to the current audio timeline.
### Client → Server: `client/hello` visualizer@v1 support object
The `visualizer@v1_support` object in [`client/hello`](#client--server-clienthello) has this structure:
- `visualizer@v1_support`: object
- Desired FFT details (to be determined)
- `buffer_capacity`: integer - max size in bytes of visualization data messages in the buffer that are yet to be displayed
### Server → Client: `stream/start` visualizer object
The `visualizer` object in [`stream/start`](#server--client-streamstart) has this structure:
- `visualizer`: object
- FFT details (to be determined)
### Server → Client: `stream/clear` visualizer
When [`stream/clear`](#server--client-streamclear) includes the visualizer role, clients should clear all buffered visualization data and continue with data received after this message.
### Server → Client: Visualization Data (Binary)
Binary messages should be rejected if there is no active stream.
- Byte 0: message type `16` (uint8)
- Bytes 1-8: timestamp (big-endian int64) - server clock time in microseconds when the visualization should be displayed by the device
- Rest of bytes: visualization data
The timestamp indicates when this visualization data should be displayed, corresponding to the audio timeline. Clients must translate this server timestamp to their local clock using the offset computed from clock synchronization.
## Color messages
This section describes messages specific to clients with the `color` role, which receive colors derived from the current audio. Colors may be extracted from album artwork, provided by the music source, or manually programmed by the server.
### Server → Client: `server/state` color object
The `color` object in [`server/state`](#server--client-serverstate) has this structure:
- `color`: object
- `timestamp`: integer - server clock time in microseconds for when these colors are valid
- `background_dark?`: integer[] | null - background color suitable for dark mode as `[R, G, B]` with values 0-255. The server must ensure a minimum WCAG contrast ratio of 4.5:1 with white text and with `on_dark` (if also present).
- `background_light?`: integer[] | null - background color suitable for light mode as `[R, G, B]` with values 0-255. The server must ensure a minimum WCAG contrast ratio of 4.5:1 with black text and with `on_light` (if also present).
- `primary?`: integer[] | null - the dominant color, as `[R, G, B]` with values 0-255. Not adjusted for contrast.
- `accent?`: integer[] | null - a secondary or complementary color, as `[R, G, B]` with values 0-255. Not adjusted for contrast.
- `on_dark?`: integer[] | null - a light color suitable for use on dark backgrounds, as `[R, G, B]` with values 0-255
- `on_light?`: integer[] | null - a dark color suitable for use on light backgrounds, as `[R, G, B]` with values 0-255

View File

@@ -0,0 +1,159 @@
# ABOUTME: CI workflow for pull requests and main branch pushes
# ABOUTME: Tests on Linux, builds player + server on Linux/macOS/Windows
name: CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
# Tell every `go` invocation across every job to drop the libopusfile parts of
# gopkg.in/hraban/opus.v2. Our code never calls the opus.Stream API, and the
# tag lets us skip installing libopusfile-dev / opusfile / mingw-w64-x86_64-opusfile
# on the runners and lets the released binary not depend on libopusfile0 at runtime.
env:
GOFLAGS: -tags=nolibopusfile
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.24'
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y libopus-dev libasound2-dev
- name: Run tests
run: go test -v -race -coverprofile=coverage.out -covermode=atomic ./...
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
file: ./coverage.out
fail_ci_if_error: false
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.24'
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y libopus-dev libasound2-dev shellcheck
- name: Shellcheck
run: shellcheck scripts/*.sh install-deps.sh
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: latest
args: --timeout=5m
build:
name: Build ${{ matrix.os }}/${{ matrix.arch }}
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- os: linux
arch: amd64
runner: ubuntu-latest
ext: ""
- os: linux
arch: arm64
runner: ubuntu-24.04-arm
ext: ""
- os: darwin
arch: arm64
runner: macos-latest
ext: ""
- os: windows
arch: amd64
runner: windows-latest
ext: ".exe"
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version: '1.24'
# Linux dependencies
- name: Install dependencies (Linux)
if: matrix.os == 'linux'
run: |
sudo apt-get update
sudo apt-get install -y libopus-dev libasound2-dev
# macOS dependencies
- name: Install dependencies (macOS)
if: matrix.os == 'darwin'
run: brew install opus
# Windows dependencies via MSYS2
- name: Install dependencies (Windows)
if: matrix.os == 'windows'
uses: msys2/setup-msys2@v2
with:
msystem: MINGW64
update: false
install: >-
mingw-w64-x86_64-gcc
mingw-w64-x86_64-pkg-config
mingw-w64-x86_64-opus
- name: Get version
id: version
shell: bash
run: echo "VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo dev)" >> $GITHUB_OUTPUT
# Linux + macOS build
- name: Build (Unix)
if: matrix.os != 'windows'
env:
CGO_ENABLED: "1"
run: |
LDFLAGS="-X github.com/Sendspin/sendspin-go/internal/version.Version=${{ steps.version.outputs.VERSION }}"
go build -ldflags "${LDFLAGS}" -o sendspin-player-${{ matrix.os }}-${{ matrix.arch }} .
go build -ldflags "${LDFLAGS}" -o sendspin-server-${{ matrix.os }}-${{ matrix.arch }} ./cmd/sendspin-server
# Windows build — needs MSYS2 MinGW on PATH for CGO
- name: Build (Windows)
if: matrix.os == 'windows'
shell: cmd
run: |
set PATH=D:\a\_temp\msys64\mingw64\bin;%PATH%
set CGO_ENABLED=1
set CC=gcc
set LDFLAGS=-X github.com/Sendspin/sendspin-go/internal/version.Version=${{ steps.version.outputs.VERSION }}
go build -ldflags "%LDFLAGS%" -o sendspin-player-windows-amd64.exe .
go build -ldflags "%LDFLAGS%" -o sendspin-server-windows-amd64.exe .\cmd\sendspin-server
- name: Upload player
uses: actions/upload-artifact@v4
with:
name: sendspin-player-${{ matrix.os }}-${{ matrix.arch }}
path: sendspin-player-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.ext }}
- name: Upload server
uses: actions/upload-artifact@v4
with:
name: sendspin-server-${{ matrix.os }}-${{ matrix.arch }}
path: sendspin-server-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.ext }}

View File

@@ -0,0 +1,96 @@
# ABOUTME: Runs the Sendspin protocol conformance suite against this PR
# ABOUTME: Pins sendspin-go to the PR checkout via CONFORMANCE_REPO_SENDSPIN_GO env var
name: Conformance
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
workflow_dispatch:
concurrency:
group: conformance-${{ github.ref }}
cancel-in-progress: true
# Match ci.yml / release.yml — the conformance harness invokes `go build`
# under the hood when it spins up the sendspin-go adapter, and we want
# that build to skip libopusfile linking the same way the released
# binaries do.
env:
GOFLAGS: -tags=nolibopusfile
jobs:
conformance:
name: Protocol conformance
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout sendspin-go
uses: actions/checkout@v4
with:
path: sendspin-go
- name: Checkout conformance harness
uses: actions/checkout@v4
with:
repository: Sendspin/conformance
path: conformance
ref: main
- name: Clone aiosendspin (reference peer)
run: |
git clone --depth 1 https://github.com/Sendspin/aiosendspin.git \
conformance/repos/aiosendspin
- name: Clone sendspin-cli (supplies the FLAC test fixture)
run: |
git clone --depth 1 https://github.com/Sendspin/sendspin-cli.git \
conformance/repos/sendspin-cli
- name: Pin sendspin-go adapter at the PR checkout
run: |
mkdir -p conformance/repos
ln -sfn ${{ github.workspace }}/sendspin-go conformance/repos/sendspin-go
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
- name: Set up uv
uses: astral-sh/setup-uv@v3
with:
python-version: '3.12'
- name: Install harness dependencies
working-directory: conformance
run: uv sync
- name: Run conformance scenarios
working-directory: conformance
env:
CONFORMANCE_REPO_SENDSPIN_GO: ${{ github.workspace }}/sendspin-go
run: |
uv run python scripts/run_all.py \
--from sendspin-go \
--to aiosendspin,sendspin-go \
--results-dir results/ \
--environment-id sendspin-go-ci \
--environment-name "sendspin-go CI (${{ github.ref_name }})"
- name: Detect regressions against published baseline
working-directory: conformance
run: |
uv run python scripts/detect_regressions.py \
--results-dir results/ \
--github-summary
- name: Upload conformance report
if: always()
uses: actions/upload-artifact@v4
with:
name: conformance-report
path: conformance/results/
retention-days: 30

View File

@@ -0,0 +1,196 @@
# ABOUTME: Release workflow triggered by version tags
# ABOUTME: Builds player + server for Linux/macOS/Windows and creates GitHub release
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
# Drop the libopusfile parts of gopkg.in/hraban/opus.v2 so the released
# binary doesn't link libopusfile.so.0 / opusfile.dylib at runtime. We
# never call the opus.Stream API. See ci.yml for the same setup.
env:
GOFLAGS: -tags=nolibopusfile
jobs:
test:
name: Test Before Release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.24'
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y libopus-dev libasound2-dev
- name: Run tests
run: go test -v -race ./...
- name: Lint
uses: golangci/golangci-lint-action@v6
with:
version: latest
args: --timeout=5m
build:
name: Build ${{ matrix.os }}/${{ matrix.arch }}
needs: test
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- os: linux
arch: amd64
runner: ubuntu-latest
ext: ""
- os: linux
arch: arm64
runner: ubuntu-24.04-arm
ext: ""
- os: darwin
arch: arm64
runner: macos-latest
ext: ""
- os: windows
arch: amd64
runner: windows-latest
ext: ".exe"
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version: '1.24'
- name: Install dependencies (Linux)
if: matrix.os == 'linux'
run: |
sudo apt-get update
sudo apt-get install -y libopus-dev libasound2-dev
- name: Install dependencies (macOS)
if: matrix.os == 'darwin'
run: brew install opus
- name: Install dependencies (Windows)
if: matrix.os == 'windows'
uses: msys2/setup-msys2@v2
with:
msystem: MINGW64
update: false
install: >-
mingw-w64-x86_64-gcc
mingw-w64-x86_64-pkg-config
mingw-w64-x86_64-opus
- name: Get version
id: version
shell: bash
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- name: Build (Unix)
if: matrix.os != 'windows'
env:
CGO_ENABLED: "1"
run: |
LDFLAGS="-X github.com/Sendspin/sendspin-go/internal/version.Version=${{ steps.version.outputs.VERSION }} -s -w"
go build -ldflags "${LDFLAGS}" -o sendspin-player-${{ matrix.os }}-${{ matrix.arch }} .
go build -ldflags "${LDFLAGS}" -o sendspin-server-${{ matrix.os }}-${{ matrix.arch }} ./cmd/sendspin-server
- name: Build (Windows)
if: matrix.os == 'windows'
shell: cmd
run: |
set PATH=D:\a\_temp\msys64\mingw64\bin;%PATH%
set CGO_ENABLED=1
set CC=gcc
set LDFLAGS=-X github.com/Sendspin/sendspin-go/internal/version.Version=${{ steps.version.outputs.VERSION }} -s -w
go build -ldflags "%LDFLAGS%" -o sendspin-player-windows-amd64.exe .
go build -ldflags "%LDFLAGS%" -o sendspin-server-windows-amd64.exe .\cmd\sendspin-server
- name: Create archives (Unix)
if: matrix.os != 'windows'
run: |
tar czf sendspin-player-${{ matrix.os }}-${{ matrix.arch }}.tar.gz sendspin-player-${{ matrix.os }}-${{ matrix.arch }}
tar czf sendspin-server-${{ matrix.os }}-${{ matrix.arch }}.tar.gz sendspin-server-${{ matrix.os }}-${{ matrix.arch }}
- name: Create archives (Windows)
if: matrix.os == 'windows'
shell: bash
run: |
7z a sendspin-player-windows-amd64.zip sendspin-player-windows-amd64.exe
7z a sendspin-server-windows-amd64.zip sendspin-server-windows-amd64.exe
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: release-${{ matrix.os }}-${{ matrix.arch }}
path: |
sendspin-player-${{ matrix.os }}-${{ matrix.arch }}.*
sendspin-server-${{ matrix.os }}-${{ matrix.arch }}.*
release:
name: Create Release
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get version
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
pattern: release-*
merge-multiple: true
- name: List artifacts
run: ls -lh artifacts/
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.VERSION }}
name: ${{ steps.version.outputs.VERSION }}
body: |
## Sendspin ${{ steps.version.outputs.VERSION }}
### Downloads
| Platform | Player | Server |
|----------|--------|--------|
| Linux x86_64 | `sendspin-player-linux-amd64.tar.gz` | `sendspin-server-linux-amd64.tar.gz` |
| Linux ARM64 | `sendspin-player-linux-arm64.tar.gz` | `sendspin-server-linux-arm64.tar.gz` |
| macOS Apple Silicon | `sendspin-player-darwin-arm64.tar.gz` | `sendspin-server-darwin-arm64.tar.gz` |
| Windows x86_64 | `sendspin-player-windows-amd64.zip` | `sendspin-server-windows-amd64.zip` |
### Daemon mode (Linux)
```bash
tar xzf sendspin-player-linux-amd64.tar.gz
sudo install -m 755 sendspin-player-linux-amd64 /usr/local/bin/sendspin-player
# Copy the systemd unit from dist/systemd/ in the source repo, then:
sudo systemctl enable --now sendspin-player
```
files: artifacts/*
draft: false
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

41
third_party/sendspin-go/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# Binaries
/sendspin-player
/sendspin-server
/resonate-player
/resonate-server
/test-sync
/ma-player
*.exe
*.dll
*.so
*.dylib
bin/
dist/
# Test binaries
*.test
# Coverage
*.out
coverage.html
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Logs
*.log
# OS
.DS_Store
Thumbs.db
output.txt
sendspin-go
resonate-go
prompts/
# Worktrees
.worktrees/

33
third_party/sendspin-go/.golangci.yml vendored Normal file
View File

@@ -0,0 +1,33 @@
# ABOUTME: golangci-lint configuration for CI linting
# ABOUTME: Defines linters and rules for code quality checks
run:
timeout: 5m
tests: true
linters:
disable-all: true
enable:
- gosimple
- govet
- ineffassign
- unused
- gofmt
- goimports
- misspell
linters-settings:
gocyclo:
min-complexity: 25
issues:
exclude-use-default: true
max-issues-per-linter: 50
max-same-issues: 10
exclude-rules:
# Exclude linters from test files
- path: _test\.go
linters:
- errcheck
- gocyclo
- dupl

View File

@@ -0,0 +1,39 @@
# ABOUTME: Pre-commit hooks configuration for code quality checks
# ABOUTME: Runs formatting, linting, and tests before each commit
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- id: check-merge-conflict
- id: mixed-line-ending
- repo: https://github.com/dnephin/pre-commit-golang
rev: v0.5.1
hooks:
- id: go-fmt
- id: go-imports
- id: go-mod-tidy
- id: golangci-lint
args: ['--timeout=5m']
- id: go-unit-tests
args: ['-race', '-v', './...']
- repo: local
hooks:
- id: go-mod-verify
name: go mod verify
entry: go mod verify
language: system
files: go\.(mod|sum)$
pass_filenames: false
- id: go-build-all
name: go build all binaries
entry: bash -c 'go build -v -o sendspin-player . && go build -v -o sendspin-server ./cmd/sendspin-server'
language: system
files: \.go$
pass_filenames: false

192
third_party/sendspin-go/CHANGELOG.md vendored Normal file
View File

@@ -0,0 +1,192 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.9.0] - 2025-10-25
### Added
**Library-First Architecture**
- Complete restructure from CLI-focused to library-first design
- Three-tier architecture: high-level convenience API, component APIs, and private implementation
- Comprehensive public API in `pkg/` for library consumers
- CLI tools reimplemented as thin wrappers using the public library
**Public API Packages**
- `pkg/sendspin` - High-level Player and Server APIs for most use cases
- `Player` - Connect to servers and play synchronized audio with volume control, callbacks
- `Server` - Serve audio to multiple clients with custom source support
- `AudioSource` - Interface for custom audio sources
- `FileSource()` - Read audio from FLAC, MP3, WAV files
- `TestToneSource()` - Generate test tones for debugging
- `pkg/audio` - Core audio types and utilities
- `Format` - Audio stream format descriptor (codec, sample rate, channels, bit depth)
- `Buffer` - Decoded PCM audio with timestamp information
- Sample conversion functions for 16-bit and 24-bit audio
- `pkg/audio/decode` - Audio decoders for multiple codecs
- `Decoder` - Common interface for all decoders
- PCM decoder - 16-bit and 24-bit support
- Opus decoder - with int16 to int32 conversion
- FLAC decoder - stub for future implementation
- MP3 decoder - with int16 to int32 conversion
- `pkg/audio/encode` - Audio encoders
- `Encoder` - Common interface for all encoders
- PCM encoder - 16-bit and 24-bit support
- Opus encoder - with int32 to int16 conversion
- `pkg/audio/resample` - Sample rate conversion
- Linear interpolation resampler
- Support for upsampling and downsampling
- Multi-channel support
- `pkg/audio/output` - Audio playback interfaces
- `Output` - Common interface for playback backends
- PortAudio implementation for cross-platform audio
- `pkg/protocol` - Sendspin wire protocol
- Message types for client/server communication
- WebSocket client implementation
- Protocol version negotiation
- `pkg/sync` - Clock synchronization
- NTP-style clock sync with Sendspin servers
- Round-trip time measurement
- Quality tracking for sync accuracy
- `pkg/discovery` - mDNS service discovery
- Discover Sendspin servers on local network
- Advertise server availability
- Service registration and browsing
**Examples**
- `examples/basic-player` - Simple audio player example
- `examples/basic-server` - Simple audio server example
- `examples/custom-source` - Custom audio source implementation
**Documentation**
- Comprehensive godoc comments for all public types and functions
- Package-level documentation for each public package
- Examples in README demonstrating library usage
- Complete refactoring design document
**Testing**
- 180+ tests covering all public APIs
- Integration tests for Player and Server
- Unit tests for audio processing components
- Codec-specific tests for encoders and decoders
### Changed
**Breaking Changes**
- CLI tools now use public library APIs instead of internal implementations
- Internal packages moved to `pkg/` for public consumption
- Audio processing now consistently uses int32 samples in 24-bit range
**Architecture**
- Player CLI (`sendspin-player`) - Thin wrapper around `pkg/sendspin.Player`
- Server CLI (`sendspin-server`) - Thin wrapper around `pkg/sendspin.Server`
- All audio processing moved to reusable public packages
- Clean separation between library code and CLI code
### Fixed
- Consistent sample format across all audio processing components
- Proper resource cleanup in decoders and encoders
- Thread-safe clock synchronization
- Robust error handling in all public APIs
### Technical Details
**Audio Pipeline**
- All decoders output int32 samples in 24-bit range for consistent hi-res audio
- All encoders accept int32 samples and convert as needed for codec
- Resampler uses linear interpolation for quality upsampling/downsampling
- PortAudio output converts int32 to int16 for playback
**Wire Protocol**
- WebSocket-based streaming with binary audio frames
- Clock sync messages for precise timing
- Metadata messages for track information
- Client state messages for monitoring
**Clock Synchronization**
- NTP-style round-trip time measurement
- Multiple sync samples for accuracy
- Quality tracking and bad sample rejection
- Thread-safe concurrent access
**Service Discovery**
- mDNS-based server discovery
- Automatic service registration
- Server name and capability advertising
### Migration Guide
For users of pre-1.0.0 versions, the library API is now the recommended way to use Sendspin:
**Old (Internal API):**
```go
// Not recommended - internal packages
import "github.com/Sendspin/sendspin-go/internal/player"
```
**New (Public API):**
```go
// Recommended - public library API
import "github.com/Sendspin/sendspin-go/pkg/sendspin"
player, err := sendspin.NewPlayer(sendspin.PlayerConfig{
ServerAddr: "localhost:8927",
PlayerName: "Living Room",
Volume: 80,
})
```
### Version Information
- Go 1.23+
- Supports hi-res audio up to 192kHz/24-bit
- Cross-platform: macOS, Linux, Windows
- Architecture: x86_64, ARM64
---
## [0.3.0] - 2025-10-24
### Added
- Hi-res audio support up to 192kHz/24-bit
- Multi-source audio support
- Server TUI with real-time client monitoring
- Comprehensive server and player documentation
### Fixed
- Audio timing and distortion issues
- Buffer management improvements
- Clock synchronization accuracy
---
## [0.2.0] - 2025-10-20
### Added
- Basic player and server functionality
- Clock synchronization
- mDNS discovery
- TUI for player
### Changed
- Initial implementation
---
## [0.1.0] - 2025-10-15
### Added
- Initial project structure
- Basic audio streaming
- WebSocket protocol

103
third_party/sendspin-go/CLAUDE.md vendored Normal file
View File

@@ -0,0 +1,103 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This guidance is aimed at Claude Code but may also be suitable for other AI tooling, such as GitHub Copilot and OpenAI Codex.
## Project Overview
`sendspin-go` is the Go implementation of the [Sendspin Protocol](https://github.com/Sendspin/website/blob/main/src/spec.md) for synchronized multi-room audio streaming. It ships as a library (`pkg/`) plus two CLI binaries: `sendspin-player` (root `main.go`) and `sendspin-server` (`./cmd/sendspin-server`). The Python sibling is [`aiosendspin`](https://github.com/Sendspin/aiosendspin); the two implementations are wire-compatible and share the role-family vocabulary (`player`, `controller`, `metadata`, `artwork`, `visualizer`).
**Note**: If uncertain about how something in Sendspin is supposed to work, fetch and refer to the [protocol specification](https://github.com/Sendspin/website/blob/main/src/spec.md) for authoritative implementation details.
## Commands
Native deps: `libopus` only. FLAC is pure-Go (`mewkiz/flac`); the Makefile, CI, and release pipelines all build with `GOFLAGS=-tags=nolibopusfile` so `gopkg.in/hraban/opus.v2`'s `opus.Stream` parts (the only consumer of `libopusfile`) are skipped and the binary doesn't link `libopusfile` at runtime. Use `./install-deps.sh` (handles brew/apt/dnf/pacman) or the per-OS commands in README.md. `ffmpeg` is only required for HLS/m3u8 server input. If you ever need to build with the opus.Stream API, override the tag: `make BUILDTAGS= test`.
```bash
make # Build sendspin-player + sendspin-server
make player # Build sendspin-player only
make server # Build sendspin-server only
make test # go test ./...
make test-coverage # -race + HTML coverage report
make lint # golangci-lint --timeout=5m
make conformance # Run protocol conformance suite (clones ../conformance on first run; needs uv)
go test ./pkg/sendspin -run TestPlayer_Connect -race -v # Single test
```
Pre-commit (`.pre-commit-config.yaml`) runs gofmt, goimports, go-mod-tidy, golangci-lint, and `go test -race -v ./...` on every commit. Run pre-commit before pushing any commit to ensure it is valid.
## Architecture
Library-first. The public API in `pkg/` is what consumers import; the CLI binaries are thin wrappers. `internal/` is private to the module by Go's rules — only `pkg/sendspin` legitimately reaches into it.
### Public layers (`pkg/`)
- **`pkg/sendspin`** (`receiver.go`, `player.go`, `server.go`): High-level API. The library splits the player into a `Receiver` (connect, handshake, clock sync, decode, schedule — emits `<-chan audio.Buffer`) and a `Player` (a thin wrapper composing `Receiver` + `pkg/audio/output.Output`). Use `Receiver` directly for visualizers, DSP, or custom output backends — no audio device required. Each `Receiver`/`Player` owns its own `*sync.ClockSync`; the package-level `SetGlobalClockSync` / `ServerMicrosNow` shims are deprecated.
- **`pkg/audio`** (`types.go`, `resample.go`, plus `decode/`, `encode/`, `output/` subpackages): Core `Format` and `Buffer` types, sample conversion, linear resampler, codec encoders/decoders (PCM, Opus, FLAC). `output` ships a single `malgo` backend; the `oto` backend was removed in v1.2.
- **`pkg/protocol`** (`messages.go`, `client.go`, `server_conn.go`): Wire messages plus `Client` (player side) and `ServerConn` (CGO-free helpers for serving binary frames).
- **`pkg/sync`** (`clock.go`, `timefilter.go`): `ClockSync` plus `TimeFilter`, the 2D Kalman filter tracking offset and drift per the Sendspin time-filter spec. This is what makes hi-res multi-room sync actually work; do not regress it without re-running `make conformance`.
- **`pkg/discovery`** (`mdns.go`): mDNS browse/advertise.
### Server-side group / role model (`pkg/sendspin`)
Two-level architecture, mirroring `aiosendspin` so the wire behavior matches across implementations.
- **`Group`** (`group.go`): Owns the typed event bus for one playback group. Publishes `ClientJoinedEvent`, `ClientLeftEvent`, `ClientStateChangedEvent`, `GroupStateChangedEvent`, etc. `ClientJoinedEvent` carries a live `*ServerClient` (the connection is fully alive at publish); `ClientLeftEvent` intentionally drops the pointer because the client is mid-teardown.
- **`GroupRole`** (`group_role.go`): One implementation per role family, coordinating across all member roles in the group. Built-in implementations: `ControllerGroupRole` (`role_controller.go`), `MetadataGroupRole` (`role_metadata.go`), `PlayerGroupRole` (`role_player.go`). Add new server-side behavior by writing a new `GroupRole` and registering it via `activateRoles`, **not** by editing `server_dispatch.go` directly.
- **`ServerClient`** (`server_client.go`): Per-connection state with typed accessors (`State()`, `Volume()`, `Muted()`, `Codec()`). Message dispatch lives in `server_dispatch.go`, audio streaming in `server_stream.go`, and per-client send-ahead pacing in `buffer_tracker.go`.
### Audio Pipeline
```
Server: AudioSource → codec negotiation per client (Opus/FLAC/PCM)
→ 20 ms chunks tagged monotonic-µs server timestamps
→ sent ~500 ms ahead → WebSocket binary frames
Player: protocol.Client → pkg/sync (Kalman-mapped local time)
→ Scheduler priority queue (200 ms startup buffer)
→ pkg/audio/output.Malgo
```
Binary message-type IDs encode role bits 72 / slot bits 10 per spec. Binary messages use a 9-byte header (1B message type + 8B `timestamp_us`); audio chunk = type 4. Artwork has its own slot via `protocol.StreamStart.Artwork`.
### Configuration & Daemon Mode
`pkg/sendspin/config.go` provides `PlayerConfigFile` / `ServerConfigFile` plus `LoadPlayerConfig` / `LoadServerConfig` and `ApplyEnvAndFile`. Precedence is **CLI > env (`SENDSPIN_PLAYER_*` / `SENDSPIN_SERVER_*`) > YAML file > built-in default**. Default search paths: `$SENDSPIN_*_CONFIG`, `~/.config/sendspin/*.yaml`, `/etc/sendspin/*.yaml`. Both binaries accept `--config` and `--daemon` (the latter logs to stdout for journalctl). Annotated examples and systemd units live in `dist/config/` and `dist/systemd/`; `make install-{player,server}-daemon` installs them.
### Internal layout (`internal/`)
- **`internal/server`**: Audio engine, source decoders, Opus/FLAC encoders, resampler, server TUI. `pkg/sendspin.Server` is a façade over this.
- **`internal/ui`**: bubbletea models, hotkey/device-picker widgets shared by both binaries.
- **`internal/discovery`**: mDNS plumbing (TXT parsing, browse loops) used by `pkg/discovery`.
- **`internal/version`**: ldflags version target.
The legacy `internal/{app,artwork,audio,client,player,protocol,sync}` packages were deleted in the v1.2 "rip-legacy-cli" sweep. Do not reintroduce that layering.
## Code Style
- Go ≥1.24. Module path is `github.com/Sendspin/sendspin-go`.
- Every `.go` file starts with two `// ABOUTME:` header lines summarizing its purpose. Match this on new files.
- Linting: golangci-lint with gosimple, govet, ineffassign, unused, gofmt, goimports, misspell, errcheck enabled; staticcheck intentionally disabled.
- Conventional commits: `type(scope): subject` (`feat`, `fix`, `refactor`, `test`, `chore`, `docs`, `style`, `build`, `ci`).
## Testing
- Tests are co-located (`_test.go` next to source).
- Integration tests use the `_integration_test.go` suffix (e.g. `flac_integration_test.go`, `client_discovery_integration_test.go`).
- Wire-format / message-type / negotiation changes must keep `make conformance` green. The harness lives in `Sendspin/conformance` and is symlinked into this checkout on first run.
- Audio invariants — 20 ms chunks (50/s), microsecond timestamps, 9-byte binary header — are interop-critical. Don't change without running the conformance suite against `aiosendspin`.
## Contribution & AI Policy
This project follows the [Open Home Foundation AI Policy](https://github.com/music-assistant/.github/blob/main/AI_POLICY.md):
- **No autonomous agents.** PRs from autonomous agents will be closed.
- **Human-in-the-loop required.** All contributions must be reviewed and understood by the contributor before submission.
- **Disclose AI-generated text.** Quote it with `>` blocks and accompany it with your own commentary explaining relevance and implications.
PRs target `main`. Recent design notes worth consulting before touching the relevant area: `docs/2026-04-12-layered-architecture-design.md`, `docs/CLOCK_SYNC_ANALYSIS.md`, `docs/FORMAT_NEGOTIATION_FIX.md`, `docs/superpowers/plans/`.

201
third_party/sendspin-go/LICENSE vendored Normal file
View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

227
third_party/sendspin-go/Makefile vendored Normal file
View File

@@ -0,0 +1,227 @@
# ABOUTME: Build automation for Sendspin Protocol server and player
# ABOUTME: Provides targets for building, testing, and cleaning binaries
.PHONY: all build player server test test-verbose test-coverage lint clean install \
build-all build-linux build-darwin help conformance \
install-daemon uninstall-daemon \
install-player-daemon uninstall-player-daemon \
install-server-daemon uninstall-server-daemon
# Version from git tag or default
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
LDFLAGS := -X github.com/Sendspin/sendspin-go/internal/version.Version=$(VERSION)
# Build with -tags=nolibopusfile so gopkg.in/hraban/opus.v2 doesn't link
# libopusfile. Our code never calls the opus.Stream API (the only consumer
# of the opusfile parts), so the linkage was free runtime-dependency weight.
# Override on the command line if you actually need opusfile (e.g. for
# upstream stream_test.go): make BUILDTAGS= test
BUILDTAGS ?= nolibopusfile
export GOFLAGS = -tags=$(BUILDTAGS)
# Default target
all: build
# Build both player and server
build: player server
# Build the player
player:
@echo "Building sendspin-player..."
go build -ldflags "$(LDFLAGS)" -o sendspin-player .
# Build the server
server:
@echo "Building sendspin-server..."
go build -ldflags "$(LDFLAGS)" -o sendspin-server ./cmd/sendspin-server
# Run tests
test:
@echo "Running tests..."
go test ./...
# Run tests with verbose output
test-verbose:
@echo "Running tests (verbose)..."
go test -v ./...
# Run tests with coverage
test-coverage:
@echo "Running tests with coverage..."
go test -v -race -coverprofile=coverage.out -covermode=atomic ./...
go tool cover -html=coverage.out -o coverage.html
@echo "Coverage report: coverage.html"
# Run linter
lint:
@echo "Running golangci-lint..."
@which golangci-lint > /dev/null || (echo "golangci-lint not installed. Install: https://golangci-lint.run/usage/install/" && exit 1)
golangci-lint run --timeout=5m
# Clean built binaries and artifacts
clean:
@echo "Cleaning binaries and artifacts..."
rm -f sendspin-player sendspin-server resonate-player resonate-server
rm -rf bin/
rm -f coverage.out coverage.html
# Install both binaries to GOPATH/bin
install:
@echo "Installing binaries..."
go install -ldflags "$(LDFLAGS)" .
go install -ldflags "$(LDFLAGS)" ./cmd/sendspin-server
# Build all platforms (like CI)
build-all: build-linux build-darwin
# Build Linux binaries
build-linux:
@echo "Building Linux binaries..."
@mkdir -p bin
GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/sendspin-player-linux-amd64 .
GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o bin/sendspin-player-linux-arm64 .
GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/sendspin-server-linux-amd64 ./cmd/sendspin-server
GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o bin/sendspin-server-linux-arm64 ./cmd/sendspin-server
# Build macOS binaries
build-darwin:
@echo "Building macOS binaries..."
@mkdir -p bin
GOOS=darwin GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/sendspin-player-darwin-amd64 .
GOOS=darwin GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o bin/sendspin-player-darwin-arm64 .
GOOS=darwin GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/sendspin-server-darwin-amd64 ./cmd/sendspin-server
GOOS=darwin GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o bin/sendspin-server-darwin-arm64 ./cmd/sendspin-server
# Install sendspin-player as a systemd daemon
install-player-daemon: player
@echo "Installing sendspin-player daemon..."
install -m 755 sendspin-player /usr/local/bin/sendspin-player
install -m 644 dist/systemd/sendspin-player.service /etc/systemd/system/sendspin-player.service
@if [ ! -f /etc/default/sendspin-player ]; then \
install -m 644 dist/systemd/sendspin-player.env /etc/default/sendspin-player; \
echo "Created /etc/default/sendspin-player — edit this file to configure."; \
else \
echo "/etc/default/sendspin-player already exists, not overwriting."; \
fi
@if [ ! -f /etc/sendspin/player.yaml ]; then \
install -d -m 755 /etc/sendspin; \
install -m 644 dist/config/player.example.yaml /etc/sendspin/player.yaml; \
echo "Created /etc/sendspin/player.yaml — edit this file to configure."; \
else \
echo "/etc/sendspin/player.yaml already exists, not overwriting."; \
fi
systemctl daemon-reload
@echo ""
@echo "Installed. To start:"
@echo " sudo systemctl enable --now sendspin-player"
@echo ""
@echo "Configure via /etc/sendspin/player.yaml (preferred) or /etc/default/sendspin-player"
# Install sendspin-server as a systemd daemon
install-server-daemon: server
@echo "Installing sendspin-server daemon..."
install -m 755 sendspin-server /usr/local/bin/sendspin-server
install -m 644 dist/systemd/sendspin-server.service /etc/systemd/system/sendspin-server.service
@if [ ! -f /etc/default/sendspin-server ]; then \
install -m 644 dist/systemd/sendspin-server.env /etc/default/sendspin-server; \
echo "Created /etc/default/sendspin-server — edit this file to configure."; \
else \
echo "/etc/default/sendspin-server already exists, not overwriting."; \
fi
@if [ ! -f /etc/sendspin/server.yaml ]; then \
install -d -m 755 /etc/sendspin; \
install -m 644 dist/config/server.example.yaml /etc/sendspin/server.yaml; \
echo "Created /etc/sendspin/server.yaml — edit this file to configure."; \
else \
echo "/etc/sendspin/server.yaml already exists, not overwriting."; \
fi
systemctl daemon-reload
@echo ""
@echo "Installed. To start:"
@echo " sudo systemctl enable --now sendspin-server"
@echo ""
@echo "Configure via /etc/sendspin/server.yaml (preferred) or /etc/default/sendspin-server"
# Aggregate: install both binaries as systemd daemons
install-daemon: install-player-daemon install-server-daemon
# Uninstall the sendspin-player systemd daemon
uninstall-player-daemon:
@echo "Removing sendspin-player daemon..."
-systemctl stop sendspin-player 2>/dev/null
-systemctl disable sendspin-player 2>/dev/null
rm -f /etc/systemd/system/sendspin-player.service
rm -f /usr/local/bin/sendspin-player
systemctl daemon-reload
@echo "Removed. /etc/default/sendspin-player and /etc/sendspin/player.yaml left in place (manual cleanup if desired)."
# Uninstall the sendspin-server systemd daemon
uninstall-server-daemon:
@echo "Removing sendspin-server daemon..."
-systemctl stop sendspin-server 2>/dev/null
-systemctl disable sendspin-server 2>/dev/null
rm -f /etc/systemd/system/sendspin-server.service
rm -f /usr/local/bin/sendspin-server
systemctl daemon-reload
@echo "Removed. /etc/default/sendspin-server and /etc/sendspin/server.yaml left in place (manual cleanup if desired)."
# Aggregate: uninstall both daemons
uninstall-daemon: uninstall-player-daemon uninstall-server-daemon
# Conformance test suite — runs the Sendspin protocol conformance harness
# against the local sendspin-go checkout. Mirrors what CI does so contributors
# and conformance maintainers can reproduce CI results locally.
#
# Assumes the conformance repo is checked out at ../conformance. Clones it
# (and the aiosendspin reference peer) on first run. The CONFORMANCE_REPO_SENDSPIN_GO
# env var points the harness at this checkout instead of the managed clone.
conformance:
@command -v uv >/dev/null 2>&1 || { echo "uv is required: https://docs.astral.sh/uv/getting-started/installation/"; exit 1; }
@command -v git >/dev/null 2>&1 || { echo "git is required"; exit 1; }
@if [ ! -d ../conformance ]; then \
echo "Cloning Sendspin/conformance into ../conformance..."; \
git clone --depth 1 https://github.com/Sendspin/conformance.git ../conformance; \
fi
@mkdir -p ../conformance/repos
@if [ ! -e ../conformance/repos/aiosendspin ]; then \
echo "Cloning aiosendspin reference peer..."; \
git clone --depth 1 https://github.com/Sendspin/aiosendspin.git ../conformance/repos/aiosendspin; \
fi
@if [ ! -e ../conformance/repos/sendspin-cli ]; then \
echo "Cloning sendspin-cli (supplies the FLAC test fixture)..."; \
git clone --depth 1 https://github.com/Sendspin/sendspin-cli.git ../conformance/repos/sendspin-cli; \
fi
@cd ../conformance && uv sync
@cd ../conformance && CONFORMANCE_REPO_SENDSPIN_GO="$(CURDIR)" uv run python scripts/run_all.py \
--from sendspin-go \
--to aiosendspin,sendspin-go \
--results-dir results/
@echo ""
@echo "Conformance report: $(CURDIR)/../conformance/results/index.html"
@echo "Raw results: $(CURDIR)/../conformance/results/data/"
# Show help
help:
@echo "Sendspin Protocol - Build Targets"
@echo ""
@echo " make - Build player and server"
@echo " make player - Build sendspin-player"
@echo " make server - Build sendspin-server"
@echo " make test - Run tests"
@echo " make test-verbose - Run tests with verbose output"
@echo " make test-coverage- Run tests with coverage report"
@echo " make lint - Run golangci-lint"
@echo " make clean - Remove built binaries"
@echo " make install - Install to GOPATH/bin"
@echo " make build-all - Build all platforms"
@echo " make build-linux - Build Linux binaries"
@echo " make build-darwin - Build macOS binaries"
@echo " make install-daemon - Install both player and server as systemd daemons (Linux, requires root)"
@echo " make install-player-daemon - Install only the player daemon"
@echo " make install-server-daemon - Install only the server daemon"
@echo " make uninstall-daemon - Remove both daemons"
@echo " make uninstall-player-daemon - Remove only the player daemon"
@echo " make uninstall-server-daemon - Remove only the server daemon"
@echo " make conformance - Run protocol conformance suite (clones ../conformance on first run)"
@echo ""
@echo "Version: $(VERSION)"

644
third_party/sendspin-go/README.md vendored Normal file
View File

@@ -0,0 +1,644 @@
# Sendspin Go
A complete Sendspin Protocol implementation in Go, featuring both server and player components for synchronized multi-room audio streaming.
**Key Highlights:**
- **Library-first design**: Use as a Go library or standalone CLI tools
- **Hi-res audio support**: Up to 192kHz/24-bit streaming
- **Multi-codec**: Opus, FLAC, MP3, PCM
- **Precise synchronization**: Microsecond-level multi-room sync
- **Easy to use**: Simple high-level APIs for common use cases
- **Flexible**: Low-level component APIs for custom implementations
- ~44mb of memory usage in Windows for sendspin-player
## Using as a Library
Install the library:
```bash
go get github.com/Sendspin/sendspin-go
```
### Quick Start - Player
```go
package main
import (
"log"
"github.com/Sendspin/sendspin-go/pkg/sendspin"
)
func main() {
// Create and configure player
player, err := sendspin.NewPlayer(sendspin.PlayerConfig{
ServerAddr: "localhost:8927",
PlayerName: "Living Room",
Volume: 80,
OnMetadata: func(meta sendspin.Metadata) {
log.Printf("Playing: %s - %s", meta.Artist, meta.Title)
},
})
if err != nil {
log.Fatal(err)
}
// Connect and play
if err := player.Connect(); err != nil {
log.Fatal(err)
}
if err := player.Play(); err != nil {
log.Fatal(err)
}
// Keep running
select {}
}
```
### Quick Start - Server
```go
package main
import (
"log"
"github.com/Sendspin/sendspin-go/pkg/sendspin"
)
func main() {
// Create test tone source (or use NewFileSource)
source := sendspin.NewTestTone(192000, 2)
// Create and start server
server, err := sendspin.NewServer(sendspin.ServerConfig{
Port: 8927,
Name: "My Server",
Source: source,
})
if err != nil {
log.Fatal(err)
}
if err := server.Start(); err != nil {
log.Fatal(err)
}
// Keep running
select {}
}
```
### More Examples
See the [examples/](examples/) directory for more complete examples:
- **[basic-player/](examples/basic-player/)** - Simple player with status monitoring
- **[basic-server/](examples/basic-server/)** - Simple server with test tone
- **[custom-source/](examples/custom-source/)** - Custom audio source implementation
### API Documentation
- **High-level API**: `pkg/sendspin` - Player and Server with simple configuration
- **Audio processing**: `pkg/audio` - Format types, codecs, resampling, output
- **Protocol**: `pkg/protocol` - WebSocket client and message types
- **Clock sync**: `pkg/sync` - Precise timing synchronization
- **Discovery**: `pkg/discovery` - mDNS service discovery
Full API documentation: https://pkg.go.dev/github.com/Sendspin/sendspin-go
## Features
### Server
- Stream audio from multiple sources:
- Local files (MP3, FLAC)
- HTTP/HTTPS streams (direct MP3)
- HLS streams (.m3u8 live radio)
- Test tone generator (440Hz)
- Automatic resampling to 48kHz for Opus compatibility
- Multi-codec support (Opus @ 256kbps, PCM fallback)
- mDNS service advertisement for automatic discovery
- Real-time terminal UI showing connected clients
- WebSocket-based streaming with precise timestamps
### Player
- Automatic server discovery via mDNS
- Multi-codec support (Opus, FLAC, PCM)
- Precise clock synchronization for multi-room audio
- Interactive terminal UI with volume control
- Jitter buffer for smooth playback
## Quickstart on Raspberry Pi
For a 64-bit Raspberry Pi OS (Lite is recommended; Bookworm or newer required):
```bash
curl -fsSL https://raw.githubusercontent.com/Sendspin/sendspin-go/main/scripts/quickstart-pi.sh | sudo bash
```
The script installs runtime dependencies, downloads the latest `sendspin-player-linux-arm64` release tarball, and registers the player as a systemd service. Add flags after `--` to pre-configure the player without editing files afterwards:
```bash
curl -fsSL https://raw.githubusercontent.com/Sendspin/sendspin-go/main/scripts/quickstart-pi.sh \
| sudo bash -s -- --name "Living Room" --device "USB Audio Device"
```
Pin to a specific release with `--version v1.6.2`. Remove the player with `--uninstall` (config in `/etc/sendspin/` is preserved). Supported on Pi 3 / 4 / 5 / Zero 2 W; not supported on 32-bit-only hardware (Pi 1 / Zero v1 / Zero W).
After install:
- View live logs: `journalctl -u sendspin-player -f`
- Discover device names: `sendspin-player --list-audio-devices`
- Edit config: `sudo nano /etc/sendspin/player.yaml`
## Installation
### Prerequisites
You'll need `pkg-config`, the Opus library, and optionally `ffmpeg` for HLS streaming:
```bash
# macOS
brew install pkg-config opus ffmpeg
# Ubuntu/Debian
sudo apt-get install pkg-config libopus-dev ffmpeg
# Fedora
sudo dnf install pkg-config opus-devel ffmpeg
```
**Windows (MSYS2):**
Install MSYS2 from https://www.msys2.org/, then in a **MSYS2 MinGW 64-bit** shell:
```bash
pacman -S mingw-w64-x86_64-gcc mingw-w64-x86_64-pkg-config \
mingw-w64-x86_64-opus
```
All subsequent `go build`, `go test`, and `make` commands must be run from a shell with the MSYS2 MinGW 64-bit toolchain on PATH:
```bash
export PATH="/c/msys64/mingw64/bin:$PATH"
```
**Notes:**
- `ffmpeg` is only required for HLS/m3u8 stream support. Local files and direct HTTP MP3 streams work without it.
- The Makefile sets `GOFLAGS=-tags=nolibopusfile`, which skips the `opus.Stream` parts of `gopkg.in/hraban/opus.v2` and avoids linking `libopusfile`. If you build with raw `go build` instead of `make`, either install `libopusfile-dev` / `opusfile` as well, or export `GOFLAGS=-tags=nolibopusfile` in your shell.
### Build
Build both server and player:
```bash
make
```
Or build individually:
```bash
make server # Builds sendspin-server
make player # Builds sendspin-player
```
On Windows, both binaries are produced in the repo root as `sendspin-server.exe` and `sendspin-player.exe`. Run them from the same MSYS2 MinGW 64-bit shell (or from cmd/PowerShell once the MSYS2 runtime DLLs are on PATH).
## Usage
### Server
Start a server with the interactive TUI (default, plays 440Hz test tone):
```bash
./sendspin-server
```
Stream a local audio file:
```bash
./sendspin-server --audio /path/to/music.mp3
./sendspin-server --audio /path/to/album.flac
```
Stream from HTTP/HTTPS:
```bash
./sendspin-server --audio http://example.com/stream.mp3
```
Stream HLS/m3u8 (live radio):
```bash
./sendspin-server --audio "https://stream.radiofrance.fr/fip/fip.m3u8?id=radiofrance"
```
Run without TUI (streaming logs to stdout):
```bash
./sendspin-server --no-tui
```
#### Server Options
- `--port` - WebSocket server port (default: 8927)
- `--name` - Server friendly name (default: hostname-sendspin-server)
- `--audio` - Audio source to stream:
- Local file path: `/path/to/music.mp3`, `/path/to/audio.flac`
- HTTP stream: `http://example.com/stream.mp3`
- HLS stream: `https://example.com/live.m3u8`
- If not specified, plays 440Hz test tone
- `--log-file` - Log file path (default: sendspin-server.log)
- `--debug` - Enable debug logging
- `--no-mdns` - Disable mDNS advertisement (clients must connect manually)
- `--no-tui` - Disable TUI, use streaming logs instead
- `--daemon` - Daemon mode: log to stdout only (journalctl-friendly), no TUI, no log file. Recommended under systemd.
- `--config` - Path to `server.yaml` config file. Default search: `$SENDSPIN_SERVER_CONFIG`, `~/.config/sendspin/server.yaml`, `/etc/sendspin/server.yaml`.
#### Server TUI
The server TUI shows:
- Server name and port
- Uptime
- Currently playing audio
- Connected clients with codec and state
- Press `q` or `Ctrl+C` to quit
#### Configuration File (`server.yaml`)
Every CLI flag has a matching key in `server.yaml`. Keys use `snake_case` (`--no-mdns``no_mdns`).
A fully-commented starter file lives at [`dist/config/server.example.yaml`](dist/config/server.example.yaml) — copy it to `~/.config/sendspin/server.yaml` (user install) or `/etc/sendspin/server.yaml` (daemon) and uncomment the keys you want to set.
**Search order** (first existing file wins; missing is not an error):
1. `--config <path>` flag
2. `$SENDSPIN_SERVER_CONFIG`
3. `~/.config/sendspin/server.yaml` (macOS: `~/Library/Application Support/sendspin/server.yaml`; Windows: `%AppData%\sendspin\server.yaml`)
4. `/etc/sendspin/server.yaml` (daemon/system-wide)
**Value precedence**, for every flag: CLI > `SENDSPIN_SERVER_<UPPER_SNAKE>` env > `server.yaml` > built-in default.
#### Running under systemd
`make install-daemon` installs both `sendspin-player` and `sendspin-server` as systemd units. To install only one side:
```bash
sudo make install-server-daemon # server only
sudo make install-player-daemon # player only
```
Then enable and start the server:
```bash
sudo systemctl enable --now sendspin-server
journalctl -u sendspin-server -f
```
Configure via `/etc/sendspin/server.yaml` (preferred) or `SENDSPIN_SERVER_OPTS` in `/etc/default/sendspin-server`.
### Player
Start a player (auto-discovers servers via mDNS):
```bash
./sendspin-player --name "Living Room"
```
Connect to a specific server manually:
```bash
./sendspin-player --server ws://192.168.1.100:8927 --name "Kitchen"
```
#### Player Options
- `--config` - Path to player.yaml config file. Default search: `$SENDSPIN_PLAYER_CONFIG`, `~/.config/sendspin/player.yaml`, `/etc/sendspin/player.yaml`.
- `--server` - Manual server WebSocket address (skips mDNS discovery)
- `--port` - Port for mDNS advertisement (default: 8927)
- `--name` - Player friendly name (default: hostname-sendspin-player)
- `--buffer-ms` - Jitter buffer size in milliseconds (default: 150)
- `--log-file` - Log file path (default: sendspin-player.log)
- `--client-id` - Override the persisted `client_id`. When set, the value is written to the config file and reused on subsequent launches.
- `--audio-device` - Playback device name (see `--list-audio-devices`). Empty = miniaudio default.
- `--list-audio-devices` - Print every playback device miniaudio can see and exit.
- `--debug` - Enable debug logging
#### Configuration File (`player.yaml`)
Every CLI flag has a matching key in `player.yaml`. Keys use `snake_case` (`--buffer-ms``buffer_ms`).
A fully-commented starter file lives at [`dist/config/player.example.yaml`](dist/config/player.example.yaml) — copy it to `~/.config/sendspin/player.yaml` (user install) or `/etc/sendspin/player.yaml` (daemon) and uncomment the keys you want to set.
**Search order** (first existing file wins; missing is not an error):
1. `--config <path>` flag
2. `$SENDSPIN_PLAYER_CONFIG`
3. `~/.config/sendspin/player.yaml` (macOS: `~/Library/Application Support/sendspin/player.yaml`; Windows: `%AppData%\sendspin\player.yaml`)
4. `/etc/sendspin/player.yaml` (daemon/system-wide)
**Value precedence**, for every flag:
1. CLI flag if passed
2. Env var `SENDSPIN_PLAYER_<UPPER_SNAKE>` (e.g. `SENDSPIN_PLAYER_BUFFER_MS=200`)
3. Config file key
4. Built-in default
**Example `player.yaml`:**
```yaml
# Identity
name: "Living Room"
client_id: "aa:bb:cc:dd:ee:ff" # auto-derived from MAC if unset
# Network
server: "" # empty = use mDNS
port: 8927
# Audio
buffer_ms: 150
static_delay_ms: 0
preferred_codec: "" # pcm (default), opus, flac
buffer_capacity: 1048576
# Device identity (shown in Music Assistant)
product_name: ""
manufacturer: ""
# Behavior
no_reconnect: false
daemon: false
no_tui: false
log_file: "sendspin-player.log"
audio_device: "" # see --list-audio-devices; empty = miniaudio default
```
#### Selecting a playback device
On Linux/macOS miniaudio picks its first-choice backend and that backend's default sink, which is usually fine on desktops but can route to the wrong card on a headless Pi (e.g. HDMI instead of a USB DAC or speaker HAT). To see what miniaudio sees and pick a specific device:
```bash
$ sendspin-player --list-audio-devices
Playback devices:
[*] HDA Intel PCH: ALC257 Analog (hw:0,0)
[ ] HDMI 0 (hw:0,3)
[ ] USB Audio Device (hw:1,0)
[*] = current default. Use --audio-device "<name>" or set audio_device: in player.yaml.
```
Then either:
```bash
./sendspin-player --audio-device "USB Audio Device"
```
Or in `player.yaml`:
```yaml
audio_device: "USB Audio Device"
```
The name must match exactly (case-sensitive, including any `(hw:X,Y)` suffix ALSA appends). If the name doesn't match, the player fails to start and lists every available device — silent fallback is deliberately not offered, because "it's not playing" is harder to debug than "it refused to start."
#### Player Identity (`client_id`)
The player sends a stable `client_id` so controllers like Music Assistant recognize it as the same player across restarts. Resolution order:
1. `--client-id` flag (when set, also persisted to the config file as `client_id`)
2. `client_id` key in the loaded `player.yaml`
3. MAC address of the primary network interface (`xx:xx:xx:xx:xx:xx`)
4. Freshly generated UUID (written to `player.yaml` as `client_id` and reused next launch)
Removing `client_id` from `player.yaml` causes the next launch to re-derive, which the server will see as a new player.
Running multiple players on one host:
```bash
./sendspin-player --name "Kitchen" --config ~/.config/sendspin/kitchen.yaml &
./sendspin-player --name "Bedroom" --config ~/.config/sendspin/bedroom.yaml &
```
Each config file holds its own `client_id`, so the two instances register as two distinct players.
#### Player TUI
The player TUI shows:
- Player name
- Server connection status
- Current audio title/artist
- Codec and sample rate
- Buffer depth
- Clock sync statistics (offset, RTT, drift)
- Playback statistics (received, played, dropped)
- Volume control (Up/Down arrows or +/- keys)
- Press `m` to mute/unmute
- Press `q` or `Ctrl+C` to quit
## Architecture
Sendspin Go is built with a **library-first architecture**, providing three layers of APIs:
### 1. High-Level API (`pkg/sendspin`)
Simple Player and Server types for common use cases:
- **Player**: Connect, play, control volume, get stats
- **Server**: Stream from AudioSource, manage clients
- **AudioSource**: Interface for custom audio sources
### 2. Component APIs
Lower-level building blocks for custom implementations:
- **`pkg/audio`**: Format types, sample conversions, Buffer
- **`pkg/audio/decode`**: PCM, Opus, FLAC, MP3 decoders
- **`pkg/audio/encode`**: PCM, Opus encoders
- **`pkg/audio/resample`**: Sample rate conversion
- **`pkg/audio/output`**: Audio playback via malgo (miniaudio); 16/24/32-bit native
- **`pkg/protocol`**: WebSocket client, message types
- **`pkg/sync`**: Clock synchronization with drift compensation
- **`pkg/discovery`**: mDNS service discovery
### 3. CLI Tools
Thin wrappers around the library APIs:
- **`cmd/sendspin-server`**: Full-featured server with TUI
- **`cmd/sendspin-player`**: Full-featured player with TUI (main.go at root)
### Server Pipeline
The server streams audio in 20ms chunks with microsecond timestamps. Audio is buffered 500ms ahead to allow for network jitter and clock synchronization.
**Processing flow:**
1. Audio source (file decoder or test tone generator)
2. Per-client codec negotiation (Opus or PCM)
3. Timestamp generation using monotonic clock
4. WebSocket binary message streaming
### Player Pipeline
The player uses a sophisticated scheduling system to ensure perfectly synchronized playback across multiple rooms.
**Processing flow:**
1. WebSocket client receives timestamped audio chunks
2. Clock sync system converts server timestamps to local time
3. Priority queue scheduler with startup buffering (200ms)
4. Persistent audio player with streaming I/O pipe
5. Software volume control and mixing
### Clock Synchronization
The player uses a simple, robust clock synchronization system:
- Calculates server loop origin on first sync
- Direct time base matching (no drift prediction)
- Continuous RTT measurement for quality monitoring
- Microsecond precision timestamps
- 500ms startup buffer matches server's lead time
## Example: Multi-Room Setup
Terminal 1 - Start the server:
```bash
./sendspin-server --audio ~/Music/favorite-album.mp3
```
Terminal 2 - Living room player:
```bash
./sendspin-player --name "Living Room"
```
Terminal 3 - Kitchen player:
```bash
./sendspin-player --name "Kitchen"
```
Both players will discover the server via mDNS and start playing in perfect sync.
## Development
Run tests:
```bash
make test
```
Clean binaries:
```bash
make clean
```
Install to GOPATH/bin:
```bash
make install
```
### Protocol conformance
The [Sendspin protocol conformance suite](https://github.com/Sendspin/conformance) runs real network scenarios between adapter binaries and compares outputs against canonical hashes. sendspin-go has a first-class adapter and is tested on every PR via the `Conformance` GitHub Actions workflow.
Run the same suite locally:
```bash
make conformance
```
This clones `Sendspin/conformance` into `../conformance` (sibling directory) and the `aiosendspin` reference peer on first run, installs the harness with `uv`, and runs `scripts/run_all.py` with this checkout pinned via the `CONFORMANCE_REPO_SENDSPIN_GO` environment variable. Requires [uv](https://docs.astral.sh/uv/getting-started/installation/) and Python 3.12+.
The published conformance report for the `main` branch is at https://sendspin.github.io/conformance/.
## Contributing
Found a bug or have a feature request? Please check existing issues or create a new one:
**[View Issues](https://github.com/Sendspin/sendspin-go/issues)**
### Recently Shipped
**v1.2.0** — drop the oto backend and unify on malgo for true 24-bit output (see [#3](https://github.com/Sendspin/sendspin-go/issues/3) and [#26](https://github.com/Sendspin/sendspin-go/pull/26))
**v1.1.0** — server-initiated client discovery, Kalman clock filter, code-path audit
### Known Issues & Todo
**Protocol & compatibility:**
- [ ] Validate all message types match latest Sendspin Protocol spec
- [ ] Test with additional Sendspin-compatible servers beyond Music Assistant
- [ ] Document protocol extensions or deviations
- [ ] Explicit protocol-version negotiation (versioned roles like `player@v1` exist; a numeric version handshake does not)
**Audio:**
- [ ] Test sample rate conversion quality (FLAC 96kHz → Opus 48kHz)
- [ ] Real FLAC streaming decoder (currently a stub — see [#34](https://github.com/Sendspin/sendspin-go/issues/34))
- [ ] Gapless playback
- [ ] Volume curve optimization (currently linear)
- [ ] Visualizer role support (FFT spectrum data)
**Stability:**
- [ ] Reconnection handling and automatic retry
- [ ] Graceful degradation on clock sync loss
- [ ] Memory leak testing for long-running sessions
- [ ] Stress testing with many clients and multi-room sync accuracy with 5+ players
**Features:**
- [ ] Album artwork end-to-end (downloader exists; not fully wired to TUI surfaces)
- [ ] Player groups and zones
- [ ] Playlist/queue management
- [ ] Cross-fade between tracks
**Developer experience:**
- [ ] Godoc examples for all public APIs
- [ ] Automated cross-platform test matrix (CI runs Linux only today)
- [ ] Docker containers for easy deployment
- [ ] Benchmarking suite
- [ ] Clean up pre-existing tech debt surfaced by [PR #26](https://github.com/Sendspin/sendspin-go/pull/26): see issues [#27#34](https://github.com/Sendspin/sendspin-go/issues)
### Roadmap
**Released**
- **v1.2.0** — oto backend removed, malgo is the only audio output, true 24-bit pipeline end-to-end
- **v1.1.0** — server-initiated client discovery, Kalman time filter, protocol audit fixes
- **v1.0.0** — initial stable release, Music Assistant compatibility, precise multi-room sync
**Planned**
- **v2.0.0 (Advanced Multi-Room)** — player groups and zones, synchronized playback controls, playlist management
## Protocol
Implements the [Sendspin Protocol](https://github.com/Sendspin/spec) specification.
**Implementation Status:**
- ✅ WebSocket transport
- ✅ Client/Server handshake with versioned role negotiation (`player@v1`, `metadata@v1`)
- ✅ Clock synchronization (NTP-style, two-dimensional Kalman filter on offset + drift)
- ✅ Audio streaming (binary frames, microsecond timestamps)
- ✅ Metadata messages (via `server/state`)
- ✅ Control commands (volume, mute)
- ✅ Multi-codec support (Opus with server-side resampling, 24-bit PCM)
- ✅ True 24-bit audio output via malgo (v1.2.0)
- ✅ Server-initiated client discovery (v1.1.0)
- ⚠️ Album artwork — downloader exists, not fully wired through to all TUI surfaces
- ⚠️ Visualizer role (planned)

View File

@@ -0,0 +1,206 @@
// ABOUTME: Entry point for Sendspin Protocol server
// ABOUTME: Thin CLI wrapper around pkg/sendspin.Server with TUI support
package main
import (
"flag"
"fmt"
"io"
"log"
"os"
"os/signal"
"syscall"
"time"
"github.com/Sendspin/sendspin-go/internal/server"
"github.com/Sendspin/sendspin-go/pkg/sendspin"
)
var (
port = flag.Int("port", 8927, "WebSocket server port")
name = flag.String("name", "", "Server friendly name (default: hostname-sendspin-server)")
logFile = flag.String("log-file", "sendspin-server.log", "Log file path")
debug = flag.Bool("debug", false, "Enable debug logging")
noMDNS = flag.Bool("no-mdns", false, "Disable mDNS advertisement")
noTUI = flag.Bool("no-tui", false, "Disable TUI, use streaming logs instead")
audioFile = flag.String("audio", "", "Audio source to stream (MP3, FLAC, HTTP URL, HLS). Default: test tone")
discoverClients = flag.Bool("discover-clients", false, "Enable server-initiated discovery: browse _sendspin._tcp and dial out to clients")
daemon = flag.Bool("daemon", false, "Daemon mode: log to stdout only (journalctl-friendly), no TUI, no log file")
configPath = flag.String("config", "", "Path to server.yaml config file. Default search: $SENDSPIN_SERVER_CONFIG, ~/.config/sendspin/server.yaml, /etc/sendspin/server.yaml.")
)
func main() {
flag.Parse()
// Overlay YAML file and SENDSPIN_SERVER_* env vars onto flag vars for
// anything the user didn't set on the CLI. --config is excluded because
// putting it in the config file would be circular.
setByUser := map[string]bool{"config": true}
flag.Visit(func(f *flag.Flag) { setByUser[f.Name] = true })
cfg, _, err := sendspin.LoadServerConfig(*configPath)
if err != nil {
log.Fatalf("config: %v", err)
}
if err := sendspin.ApplyEnvAndFile(flag.CommandLine, setByUser, sendspin.ServerEnvPrefix, cfg.AsStringMap()); err != nil {
log.Fatalf("config overlay: %v", err)
}
useTUI := !(*noTUI || *daemon)
if *daemon {
// Daemon mode: log to stdout only. systemd/journalctl captures stdout
// and adds its own timestamps, so we keep ours for grep-ability.
log.SetOutput(os.Stdout)
} else {
f, err := os.OpenFile(*logFile, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0600)
if err != nil {
log.Fatalf("error opening log file: %v", err)
}
defer f.Close()
if useTUI {
// Log to file only when TUI is running; otherwise the log would stomp the TUI
log.SetOutput(f)
} else {
log.SetOutput(io.MultiWriter(os.Stdout, f))
}
}
serverName := *name
if serverName == "" {
hostname, err := os.Hostname()
if err != nil {
hostname = "unknown"
}
serverName = fmt.Sprintf("%s-sendspin-server", hostname)
}
if !useTUI {
log.Printf("Starting Sendspin Server: %s on port %d", serverName, *port)
if *daemon {
log.Printf("Daemon mode: logging to stdout only")
}
}
var source sendspin.AudioSource
if *audioFile == "" {
source = sendspin.NewTestTone(sendspin.DefaultSampleRate, sendspin.DefaultChannels)
} else {
internalSource, err := server.NewAudioSource(*audioFile)
if err != nil {
log.Fatalf("Failed to create audio source: %v", err)
}
source = internalSource
}
srv, err := sendspin.NewServer(sendspin.ServerConfig{
Port: *port,
Name: serverName,
Source: source,
EnableMDNS: !*noMDNS,
Debug: *debug,
DiscoverClients: *discoverClients,
})
if err != nil {
log.Fatalf("Failed to create server: %v", err)
}
var tui *server.ServerTUI
var tuiDone chan struct{}
if useTUI {
tui = server.NewServerTUI(serverName, *port)
tuiDone = make(chan struct{})
go func() {
defer close(tuiDone)
if err := tui.Start(serverName, *port); err != nil {
log.Printf("TUI error: %v", err)
}
}()
time.Sleep(100 * time.Millisecond)
go func() {
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ticker.C:
updateTUI(tui, srv, source, serverName, *port)
case <-tuiDone:
return
}
}
}()
}
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
var tuiQuitChan <-chan struct{}
if tui != nil {
tuiQuitChan = tui.QuitChan()
}
serverDone := make(chan error, 1)
go func() {
serverDone <- srv.Start()
}()
serverStopped := false
select {
case sig := <-sigChan:
log.Printf("Received %v, shutting down...", sig)
case <-tuiQuitChan:
log.Printf("TUI quit requested, shutting down...")
case err := <-serverDone:
serverStopped = true
if err != nil {
log.Printf("Server error: %v", err)
}
}
srv.Stop()
if tui != nil {
tui.Stop()
<-tuiDone
}
if !serverStopped {
if err := <-serverDone; err != nil {
log.Printf("Server shutdown error: %v", err)
}
}
log.Printf("Server stopped")
}
func updateTUI(tui *server.ServerTUI, srv *sendspin.Server, source sendspin.AudioSource, serverName string, port int) {
clients := srv.Clients()
tuiClients := make([]server.ClientInfo, len(clients))
for i, c := range clients {
tuiClients[i] = server.ClientInfo{
Name: c.Name,
ID: c.ID,
Codec: c.Codec,
State: c.State,
}
}
title, artist, _ := source.Metadata()
audioTitle := title
if artist != "" && artist != "Unknown Artist" {
audioTitle = artist + " - " + title
}
tui.Update(server.ServerStatus{
Name: serverName,
Port: port,
Clients: tuiClients,
AudioTitle: audioTitle,
})
}

View 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.

View File

@@ -0,0 +1,154 @@
# Clock Synchronization Problem Analysis
## The Core Issue
The aioresonate server is rejecting all audio chunks with "Audio chunk should have played already" even though our clock synchronization appears to be working. This document explains why.
## How The Server Works
### Chunk Timestamp Calculation
```python
# When streaming starts:
play_start_time_us = int(server.loop.time() * 1_000_000) + INITIAL_PLAYBACK_DELAY_US
# For each chunk:
chunk_timestamp = play_start_time_us + (sample_offset * 1_000_000 / sample_rate)
```
### Late Chunk Detection
```python
# When sending each chunk:
now = int(server.loop.time() * 1_000_000)
if chunk_timestamp - now < 0:
logger.error("Audio chunk should have played already, skipping it")
continue
```
**Critical Discovery:** Both `chunk_timestamp` and `now` use `server.loop.time()` - the server's monotonic clock (seconds since asyncio event loop started).
## The Clock Sync Protocol
1. Client sends `t1` (client time when request sent)
2. Server receives at `t2` (server monotonic time)
3. Server responds at `t3` (server monotonic time)
4. Client receives at `t4` (client time when response received)
The server just **echoes** t2 and t3 back. It doesn't store or use the client's offset for anything!
## Why The Server Doesn't Use Client Offset
The server timestamps chunks using **only its own loop.time()**. It never adjusts chunk timestamps based on client clock offset.
This works fine for ESPHome clients running **in the same Python process** - they share the same `loop.time()` so no offset exists.
But it breaks for **external network clients** like ours who have independent monotonic clocks starting at different times.
## Our Current Approach (BROKEN)
```go
// At connection (our process time ~0s, server loop.time ~137s):
hackOffset = calculated_offset + 500000 // 137.3s + 500ms
// Later when sending time sync:
realMicros = time.Since(startTime) // Our process uptime
return realMicros + hackOffset // Try to match server's loop.time
```
**Problem:** Our time sync responses correctly show we're synchronized (~500ms ahead). But this doesn't matter because **the server never uses our clock offset when timestamping or checking chunks!**
## Why Chunks Are Rejected
Chunks are timestamped and checked entirely in server time:
```
Stream starts: server.loop.time() = 137.115s
play_start_time = 137.115 + 0.5 = 137.615s
chunk[0] timestamp = 137.615s
chunk[1] timestamp = 137.615 + 0.02s = 137.635s
When sending chunk[0]:
now = 137.620s (time has advanced while buffering)
if 137.615 - 137.620 < 0: TRUE - REJECT!
```
The chunks are getting timestamped correctly but there's apparently processing delay causing them to be late **even in server time**.
But wait - that doesn't explain the IMMEDIATE flood of errors. Unless...
## Alternative Theory: Wrong Time Base
What if there's a mismatch in how time is calculated somewhere? Like if `play_start_time_us` is set incorrectly, or if the server is mixing different time bases?
## The Real Bug
After analyzing the aioresonate source code, I believe the issue is:
**The aioresonate library was designed for in-process clients (ESPHome) and doesn't properly handle external networked clients with independent clocks.**
Specifically:
1. ✅ Clock sync protocol exists and works
2. ❌ Server never uses client offset when timestamping chunks
3. ❌ Server checks chunk lateness using only its own clock
4. ❌ No adjustment for network transmission time or client buffering
## Proposed Solution
Since we can't fix the server, we need to perfectly match the server's `loop.time()`:
```go
// At first time sync, calculate when server's loop started in Unix time
var serverLoopStartUnix int64
func ProcessSyncResponse(t1, t2, t3, t4 int64) {
if first_sync {
// t2 is server's loop.time() in microseconds
// Calculate when that loop started in Unix time
now_unix = time.Now().UnixMicro()
serverLoopStartUnix = now_unix - t2
}
}
func CurrentMicros() int64 {
// Return time that matches server's loop.time()
return time.Now().UnixMicro() - serverLoopStartUnix
}
```
This makes our timestamps **exactly match** the server's loop.time() (in microseconds).
## Why This Should Work
- Server's loop.time() = seconds since its event loop started
- Our reported time = microseconds since that same moment (calculated)
- Both use same time base
- No drift between our clock and server's clock checks
## Risks
1. **Unix time adjustments (NTP):** If system time jumps, our reported time jumps too
- Mitigation: Servers rarely have NTP jumps during playback
2. **Precision:** Unix microseconds might have different precision than loop.time()
- Mitigation: Both are microsecond-resolution
3. **Calculation error:** If our estimate of serverLoopStartUnix is wrong
- Mitigation: We calculate it from actual t2 value, should be accurate
## Next Steps
1. Implement Unix-time-based approach
2. Test with playback
3. If still failing, add detailed logging of:
- Exact chunk timestamps we receive
- Server's play_start_time from logs
- Our calculated serverLoopStartUnix
## Why Previous Attempts Failed
- **Attempt 1 (no offset):** Our monotonic time was ~137s behind server's
- **Attempt 2 (with hackOffset):** Helped but not exact match due to continued drift
- **Attempt 3 (+500ms buffer):** Made us 500ms ahead, but server doesn't care about our reported offset
All attempts failed because they tried to "synchronize" our clock with the server's, but the server **never uses that synchronization information** when handling chunks!
We need to literally return the same values the server's loop.time() would return.

View File

@@ -0,0 +1,163 @@
# Code Review Fixes - 24-bit Audio Implementation
## Date: 2025-10-25
After implementing the 24-bit audio pipeline, I performed a careful code review with "fresh eyes" and found several issues that have been fixed.
## Issues Found and Fixed
### 1. ✅ CRITICAL: Integer Overflow in Volume Control
**File:** `internal/player/output.go`
**Location:** `applyVolume()` function
**Problem:**
```go
result[i] = int32(float64(sample) * multiplier)
```
When applying volume to samples, there was no clipping protection. Samples at max 24-bit range (±8,388,607) multiplied by volume could overflow int32.
**Fix:**
```go
scaled := int64(float64(sample) * multiplier)
// Clamp to 24-bit range to prevent overflow
if scaled > audio.Max24Bit {
scaled = audio.Max24Bit
} else if scaled < audio.Min24Bit {
scaled = audio.Min24Bit
}
result[i] = int32(scaled)
```
**Impact:** Prevents audio distortion and crashes from integer overflow when volume is applied.
---
### 2. ✅ Improved Code Clarity: Better Comments
**File:** `internal/server/audio_source.go`
**Locations:** MP3Source, HTTPMP3Source, FFmpegSource Read methods
**Problem:**
Comments were confusing, referring to "int16 = 2 bytes" next to code dealing with int32 arrays.
**Before:**
```go
numBytes := len(samples) * 2 // int16 = 2 bytes
```
**After:**
```go
// Read bytes from decoder (MP3 decoder outputs int16 = 2 bytes per sample)
numBytes := len(samples) * 2
```
**Also added detailed comments for the conversion:**
```go
// Left-shift by 8 to convert 16-bit range to 24-bit range
// Example: 32767 (max 16-bit) << 8 = 8388352 (near max 24-bit 8388607)
samples[i] = int32(sample16) << 8
```
**Impact:** Makes code intention clearer for future maintainers.
---
### 3. ✅ Code Organization: Added 24-bit Constants
**File:** `internal/audio/types.go`
**Problem:**
24-bit range limits were hardcoded as magic numbers throughout the codebase.
**Fix:**
```go
const (
// 24-bit audio range constants
Max24Bit = 8388607 // 2^23 - 1
Min24Bit = -8388608 // -2^23
)
```
**Updated usages:**
- `internal/player/output.go` - Volume clipping now uses `audio.Max24Bit` and `audio.Min24Bit`
- `internal/server/test_tone_source.go` - Test tone generation uses `max24bit` constant
**Impact:** Single source of truth for 24-bit range, easier to maintain.
---
### 4. ✅ Improved Test Tone Comments
**File:** `internal/server/test_tone_source.go`
**Before:**
```go
pcmValue := int32(sample * 8388607.0 * 0.5) // 50% volume
```
**After:**
```go
// Convert to 24-bit PCM (using int32)
// Scale to 24-bit range and apply 50% volume to avoid clipping
const max24bit = 8388607 // 2^23 - 1
pcmValue := int32(sample * max24bit * 0.5)
```
**Impact:** Clarifies why 50% volume is used (to prevent clipping on sine wave peaks).
---
## Issues Considered But Not Changed
### 1. ResampledSource Error Handling
**File:** `internal/server/audio_source.go:511`
**Code:**
```go
outputSamples := r.resampler.Resample(r.inputBuffer[:n], samples)
return outputSamples, nil
```
**Analysis:**
The resampler returns the number of samples actually written. While we don't explicitly check if it filled the requested amount, this is actually correct behavior - the caller receives the actual count and can handle partial fills. No change needed.
### 2. FLAC Bit Depth Conversion
**File:** `internal/server/audio_source.go:266-280`
**Code:**
```go
if s.bitDepth == 16 {
samples[samplesRead] = sample << 8
} else if s.bitDepth == 24 {
samples[samplesRead] = sample
} else {
// For other bit depths, scale to 24-bit range
shift := s.bitDepth - 24
if shift > 0 {
samples[samplesRead] = sample >> shift
} else {
samples[samplesRead] = sample << -shift
}
}
```
**Analysis:**
The FLAC library returns samples as int32 in the native bit depth range. For 16-bit FLAC, the value is already correctly ranged ±32,768, so left-shifting by 8 is correct. For 24-bit, the value is already in the right range. The generic case handles other bit depths (8-bit, 20-bit, etc.). This logic is sound.
---
## Testing
After fixes:
- ✅ Server compiles successfully
- ✅ Player compiles successfully
- ✅ No new warnings or errors
## Summary
**Total Issues Fixed:** 4
- 1 Critical (integer overflow protection)
- 3 Code quality improvements (comments, constants)
**Lines Changed:** ~30 lines across 4 files
All fixes maintain backward compatibility and improve code robustness without changing the core 24-bit pipeline functionality.

View File

@@ -0,0 +1,89 @@
# Format Negotiation Fix
## Issue
User reported seeing "Format: pcm 48000Hz Stereo 16-bit" instead of the expected "Format: pcm 192000Hz Stereo 24-bit" when connecting to the server.
## Root Cause
The server's `negotiateCodec()` function was only checking for codec type (pcm/opus/flac) but **not matching the sample rate and bit depth** from the client's advertised `SupportFormats` array.
### Previous Behavior
```go
// Only checked codec type, ignored format details
for _, format := range client.Capabilities.SupportFormats {
if format.Codec == "opus" {
return "opus"
}
// ...
}
```
This meant:
- Server would pick "pcm" codec
- But then use its own `DefaultSampleRate` and `DefaultBitDepth`
- Never checked if client actually supports those values
- Could result in format mismatch
## Fix Applied
Updated `negotiateCodec()` to check **full format specification**:
```go
// Check newer support_formats array first (spec-compliant)
// Pick first format that matches what we can provide
for _, format := range client.Capabilities.SupportFormats {
// Check if we can provide this format
if format.Codec == "pcm" && format.SampleRate == e.source.SampleRate() && format.BitDepth == DefaultBitDepth {
return "pcm"
}
// ... opus/flac checks
}
```
### How It Works Now
1. Server iterates through client's `SupportFormats` (in order of preference)
2. For PCM, checks if server can provide the exact sample rate AND bit depth
3. Returns "pcm" codec only if there's a full format match
4. Falls back to opus/flac if no PCM match
5. Final fallback to "pcm" if no codecs match
## Client Advertisement (Already Correct)
The player was already advertising correctly:
```go
SupportFormats: []protocol.AudioFormat{
{Codec: "pcm", Channels: 2, SampleRate: 192000, BitDepth: 24}, // First!
{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: "opus", Channels: 2, SampleRate: 48000, BitDepth: 16},
}
```
## Expected Result
With test tone source (192kHz/24-bit):
- Client advertises: [192kHz/24bit, 176.4kHz/24bit, 96kHz/24bit, ..., 48kHz/16bit]
- Server has: 192kHz/24bit source
- Match found: First format (192kHz/24bit PCM)
- Client receives: `stream/start` with 192kHz/24bit
## Improved Logging
Added detailed format logging:
```
Audio engine: added client Mac with codec pcm (format: 192000Hz/24bit/2ch)
```
## Testing
To verify fix works:
1. Stop any running server/player processes
2. Rebuild server: `go build -o resonate-server ./cmd/resonate-server`
3. Start server: `./resonate-server -debug`
4. Start player: `./resonate-player -stream-logs`
5. Check player log for: `Stream starting: pcm 192000Hz 2ch 24bit`
6. Check server log for: `Audio engine: added client ... (format: 192000Hz/24bit/2ch)`
## Potential Issues Debugged
If still seeing 48kHz/16-bit:
- Check you're using the NEW binaries (rebuild both)
- Check server logs show "192000Hz/24bit" in format line
- If connected to Music Assistant instead of our server, it may only support 48kHz/16bit
- Check player capabilities are being sent correctly (enable debug logging)

View File

@@ -0,0 +1,112 @@
# Hi-Res Audio (192kHz/24-bit) Test Results
## Test Date: 2025-10-25
### Implementation Summary
Successfully refactored the entire Resonate audio pipeline from 16-bit (int16) to 24-bit (int32) depth support.
### Changes Made
#### 1. Core Type System (`internal/audio/types.go`)
- Migrated `Buffer.Samples` from `[]int16` to `[]int32`
- Added conversion helpers:
- `SampleFromInt16()` - converts 16-bit to 24-bit (left-shift by 8)
- `SampleToInt16()` - converts 24-bit to 16-bit (right-shift by 8)
- `SampleTo24Bit()` - packs int32 to 3-byte little-endian
- `SampleFrom24Bit()` - unpacks 3-byte to int32 with sign extension
#### 2. Decoder Pipeline (`internal/audio/decoder.go`)
- Updated `Decoder` interface: `Decode(data []byte) ([]int32, error)`
- PCM decoder now handles both 16-bit and 24-bit formats
- Opus decoder converts int16 output to int32 for pipeline consistency
#### 3. Server Components
- **Audio Engine** (`internal/server/audio_engine.go`):
- Updated constants: `DefaultBitDepth = 24`
- PCM encoder outputs 3 bytes per sample (24-bit little-endian)
- Opus encoder converts int32 to int16 before encoding
- **Audio Sources** (`internal/server/audio_source.go`):
- Updated `AudioSource` interface: `Read(samples []int32) (int, error)`
- MP3Source, FLACSource, HTTPSource, FFmpegSource: decode to int16, convert to int32 (×256)
- FLAC source properly handles native 24-bit files
- **Test Tone** (`internal/server/test_tone_source.go`):
- Generates true 24-bit samples using full int32 range
- Scale: 2^23 - 1 = 8,388,607 (24-bit max)
- **Resampler** (`internal/server/resampler.go`):
- Updated to use `[]int32` throughout
- Linear interpolation now preserves 24-bit precision
#### 4. Player Components
- **Output** (`internal/player/output.go`):
- Accepts int32 samples from jitter buffer
- Converts to int16 for PortAudio (native 24-bit output deferred)
- Volume control operates on int32 values
### Test Results
#### Connection & Format Negotiation ✅
```
Player log: Stream starting: pcm 192000Hz 2ch 24bit
Server log: Audio engine: added client with codec pcm
```
#### Audio Pipeline ✅
- **Sample Rate**: 192,000 Hz (192 kHz)
- **Bit Depth**: 24-bit
- **Channels**: 2 (Stereo)
- **Chunk Size**: 7,680 samples (20ms @ 192kHz × 2ch)
- **Wire Format**: 23,040 bytes per chunk (7680 × 3 bytes)
- **Buffer Ahead**: 500ms
- **Jitter Buffer**: 25 chunks at startup
#### Performance Metrics ✅
- Clock sync RTT: 186-521μs
- Timestamp accuracy: ±0.8ms to ±500ms buffer ahead
- Chunk generation: Stable at 20ms intervals
- Buffer fill: 25 chunks in <1 second
### Data Flow Verification
**Server → Wire:**
1. Test tone generates int32 samples (24-bit range: ±8,388,607)
2. `encodePCM()` packs to 3 bytes per sample (little-endian)
3. Binary frame sent over WebSocket
**Wire → Player:**
1. PCM decoder unpacks 3 bytes to int32 with sign extension
2. Samples stored in jitter buffer as int32
3. Output converts to int16 for PortAudio playback
### Architecture Notes
#### Why int32 for 24-bit?
- No native int24 type in Go
- int32 provides full 24-bit signed range (-8,388,608 to 8,388,607)
- Wire protocol uses packed 3-byte format for efficiency
- Internal int32 allows lossless processing
#### Backward Compatibility
- Opus codec: converts int32 → int16 for encoding (Opus only supports 16-bit)
- Legacy file sources: decode to int16, convert to int32 (×256 to fill 24-bit range)
- Player output: converts int32 → int16 for PortAudio (TODO: native 24-bit output)
### Future Work
1. Native 24-bit PortAudio output (currently converting to 16-bit for playback)
2. Hi-res file sources (native 24-bit FLAC/WAV decoding)
3. Performance tuning at 192kHz data rate
4. Jitter buffer optimization for hi-res
### Conclusion
**TRUE HI-RES AUDIO ACHIEVED**
The resonate-go implementation now supports genuine Hi-Res Audio:
- ✅ 192 kHz sample rate (4× CD quality)
- ✅ 24-bit depth (256× dynamic range of 16-bit)
- ✅ End-to-end int32 pipeline with 3-byte wire encoding
- ✅ Lossless PCM transmission
- ✅ Verified working with test tone generator
This exceeds Hi-Res Audio certification requirements (>48kHz OR >16-bit). We have **both**.

View File

@@ -0,0 +1,147 @@
# Music Assistant Hi-Res Issue
## Problem
Music Assistant shows "signal degraded" and only displays 44.1kHz/16bit and 48kHz/16bit as supported formats, even though our player advertises full hi-res capabilities (up to 192kHz/24bit).
## What We're Advertising
Our player sends comprehensive capabilities in `client/hello`:
```json
{
"support_formats": [
{"codec": "pcm", "channels": 2, "sample_rate": 192000, "bit_depth": 24},
{"codec": "pcm", "channels": 2, "sample_rate": 176400, "bit_depth": 24},
{"codec": "pcm", "channels": 2, "sample_rate": 96000, "bit_depth": 24},
{"codec": "pcm", "channels": 2, "sample_rate": 88200, "bit_depth": 24},
{"codec": "pcm", "channels": 2, "sample_rate": 48000, "bit_depth": 16},
{"codec": "pcm", "channels": 2, "sample_rate": 44100, "bit_depth": 16},
{"codec": "opus", "channels": 2, "sample_rate": 48000, "bit_depth": 16}
],
"support_codecs": ["pcm", "opus"],
"support_sample_rates": [192000, 176400, 96000, 88200, 48000, 44100],
"support_bit_depth": [24, 16]
}
```
Both new-style (`support_formats`) and legacy-style (separate arrays) are provided for maximum compatibility.
## Verification
When connecting directly to our own resonate-go server:
- ✅ Server correctly negotiates 192kHz/24bit PCM
- ✅ Player receives and plays hi-res audio
- ✅ Format negotiation works as expected
This proves our implementation is correct.
## Possible Causes
### 1. MA's Resonate Provider Has Hardcoded Limits
Music Assistant's built-in Resonate provider might have:
- Hardcoded maximum sample rate (48kHz)
- Hardcoded bit depth (16-bit)
- Safety filters that only expose "known good" formats
### 2. MA Filters Based on What It Can Provide
MA might be filtering player capabilities based on what formats MA itself can deliver:
- If MA's audio pipeline doesn't support >48kHz, it hides those options
- If MA's source doesn't have hi-res, it might not show hi-res player caps
### 3. MA Version Limitations
Older versions of Music Assistant might not support hi-res audio at all, regardless of player capabilities.
### 4. Protocol Parsing Bug
MA's Resonate provider might have a bug in how it parses capabilities:
- Might only look at legacy arrays and misinterpret them
- Might do AND logic instead of OR (only showing common formats)
- Might not understand the newer `support_formats` array
## Debugging Steps
### 1. Check MA Logs
Look for player connection logs:
```bash
# In Music Assistant logs, search for:
- "Resonate player connected"
- "Player capabilities"
- Your player name
```
### 2. Check MA Version
- Go to Settings → Info
- Check Music Assistant version
- Check if there are Resonate provider updates
### 3. Test Direct Connection
Compare behavior:
```bash
# Direct to our server (should work)
./resonate-server -debug -no-tui
./resonate-player -server localhost:8927 -name "test"
# Should see: "Stream starting: pcm 192000Hz 2ch 24bit"
# Through MA (shows degraded)
# Connect player to MA
# Check what format MA sends
```
### 4. Check MA Settings
- Settings → Providers → Resonate
- Look for any sample rate or quality limitations
- Check if there's a "max quality" or "sample rate" setting
## Workarounds
### Option 1: Use Direct Connection
Skip Music Assistant and connect directly to our server:
```bash
./resonate-server -audio /path/to/hires.flac
./resonate-player -server <server-ip>:8927
```
### Option 2: Request MA Enhancement
File an issue with Music Assistant to:
- Support hi-res audio (>48kHz)
- Properly parse player hi-res capabilities
- Update Resonate provider
### Option 3: Custom MA Provider
Create a custom Resonate provider for MA that:
- Properly reads player capabilities
- Doesn't filter hi-res formats
- Passes through native sample rates
## Expected vs. Actual
### Expected (Direct Connection)
```
Player advertises: 192kHz/24bit capable
Server provides: 192kHz/24bit source
Negotiation: Match! Use PCM 192kHz/24bit
Result: ✅ Hi-res audio delivered
```
### Actual (Through MA)
```
Player advertises: 192kHz/24bit capable
MA sees: Only 44.1kHz/16bit, 48kHz/16bit ???
MA sends: 48kHz/16bit (downsampled)
Result: ❌ "Signal degraded" warning
```
## Conclusion
**Our player implementation is correct.** The issue is in Music Assistant's Resonate provider, which is not properly recognizing or supporting hi-res capabilities.
**Next Steps:**
1. Get MA logs to confirm where filtering happens
2. Check MA version and update if needed
3. Consider filing MA enhancement request
4. For now, use direct connection for hi-res audio
## Testing MA Compatibility
If you want to ensure our player works with MA's current limitations, we could add a "compatibility mode" that:
- Only advertises 48kHz/16bit
- Disables hi-res formats
- Forces lowest common denominator
But this defeats the purpose of building a hi-res player! Better to fix MA or use direct connections.

View File

@@ -0,0 +1,131 @@
# Native Sample Rate Fix
## Issue
When loading a 96kHz FLAC file, the server was:
1. Resampling it down to 48kHz "for Opus compatibility"
2. Then negotiating with client
3. Client advertises 96kHz/24bit support
4. Server can't match because it already downsampled to 48kHz
5. Falls through to Opus at 48kHz
**Result:** Hi-res 96kHz FLAC delivered as Opus 48kHz 😢
## Root Cause: Premature Resampling
In `internal/server/audio_source.go:91-95`:
```go
// If source is not 48kHz, wrap with resampler for Opus compatibility
if source.SampleRate() != 48000 {
log.Printf("Resampling %s from %d Hz to 48000 Hz for Opus compatibility",
displayName, source.SampleRate())
return NewResampledSource(source, 48000), nil
}
```
This was **premature optimization** - assuming Opus would be needed before checking what the client supports!
## Fix Applied
### 1. Removed Auto-Resampling
`internal/server/audio_source.go`:
```go
// Note: We no longer auto-resample here. Sources are kept at native sample rate.
// If Opus encoding is needed and source isn't 48kHz, resampling happens per-client in audio engine.
// This allows PCM clients to receive hi-res audio at native rates!
return source, nil
```
### 2. Updated Format Negotiation
`internal/server/audio_engine.go` - `negotiateCodec()`:
**Before:** Would pick any codec without checking if it matches the source
**After:** Prioritizes formats in this order:
1. **PCM at native sample rate** (preserves hi-res quality)
2. Opus only if source is 48kHz (Opus native rate)
3. FLAC (not implemented yet)
4. Fallback to PCM
```go
// Check newer support_formats array first (spec-compliant)
// Prioritize PCM at native rate to preserve hi-res audio quality
for _, format := range client.Capabilities.SupportFormats {
// Check if client supports PCM at our native sample rate
if format.Codec == "pcm" && format.SampleRate == sourceRate && format.BitDepth == DefaultBitDepth {
return "pcm"
}
}
// If no PCM match at native rate, consider compressed codecs
for _, format := range client.Capabilities.SupportFormats {
// Only use Opus if source is 48kHz (Opus native rate)
// For other rates, prefer PCM to avoid resampling
if format.Codec == "opus" && sourceRate == 48000 {
return "opus"
}
}
```
## Expected Behavior Now
### Test Tone (192kHz/24bit)
- Client advertises: [192kHz/24bit PCM, ..., Opus]
- Server has: 192kHz/24bit source
- **Match:** PCM 192kHz/24bit ✅
- **Delivery:** `pcm 192000Hz 2ch 24bit`
### 96kHz FLAC
- Client advertises: [192kHz/24bit, 176kHz/24bit, **96kHz/24bit**, ..., Opus]
- Server has: 96kHz/24bit FLAC
- **Match:** PCM 96kHz/24bit ✅
- **Delivery:** `pcm 96000Hz 2ch 24bit`
### 48kHz MP3
- Client advertises: [..., 48kHz/16bit, Opus]
- Server has: 48kHz/16bit MP3
- **Match:** PCM 48kHz/16bit OR Opus 48kHz ✅
- **Delivery:** `pcm 48000Hz 2ch 16bit` (PCM prioritized)
### 44.1kHz MP3
- Client advertises: [..., 44.1kHz/16bit, Opus]
- Server has: 44.1kHz/16bit MP3
- **Match:** PCM 44.1kHz/16bit ✅
- **Delivery:** `pcm 44100Hz 2ch 16bit`
## What About Opus for Non-48kHz?
For now, Opus is **only** used if:
1. Source is already 48kHz, AND
2. Client advertises Opus support
If source is non-48kHz (like 96kHz FLAC):
- Server will ALWAYS prefer PCM at native rate
- No resampling, preserves full hi-res quality
- Client handles any resampling needed on their end
**Future:** Could add per-client resampling for Opus if client specifically requests it and doesn't support native PCM rate. But for hi-res use cases, PCM is always better anyway!
## Testing
```bash
# Rebuild
go build -o resonate-server ./cmd/resonate-server
# Test 1: 192kHz test tone (should use PCM 192kHz)
./resonate-server -debug -no-tui
# Test 2: 96kHz FLAC (should use PCM 96kHz, NOT resample to 48kHz)
./resonate-server -debug -no-tui -audio ~/Downloads/Sample_BeeMoved_96kHz24bit.flac
# Player should show:
# - Test 1: "Stream starting: pcm 192000Hz 2ch 24bit"
# - Test 2: "Stream starting: pcm 96000Hz 2ch 24bit"
```
## Benefits
✅ Hi-res audio preserved at native sample rates
✅ No unnecessary downsampling
✅ PCM prioritized over lossy compression
✅ Better sound quality for hi-res files
✅ Still supports Opus when appropriate (48kHz sources)

View File

@@ -0,0 +1,430 @@
# Phase 1 Implementation Complete ✅
**Date:** 2025-10-26
**Status:** Implementation Complete, Ready for Testing
---
## Summary
Successfully implemented both Phase 1 fixes to enable true hi-res audio and optimize bandwidth:
1. **✅ 24-bit Output Support** - Replaced oto with malgo for true 24-bit playback
2. **✅ Opus Resampling** - Added automatic resampling for bandwidth optimization
All code compiles successfully. Ready for testing.
---
## Changes Made
### 1. Created malgo Output Backend
**File:** `pkg/audio/output/malgo.go` (new, 350 lines)
**Features:**
- ✅ True 24-bit output support (FormatS24)
- ✅ Also supports 16-bit and 32-bit formats
- ✅ Format re-initialization support (fixes oto limitation)
- ✅ Ring buffer for callback-based audio architecture
- ✅ No external dependencies on macOS/Windows
**Key Implementation Details:**
```go
// Supports 16/24/32-bit output
func (m *Malgo) Open(sampleRate, channels, bitDepth int) error {
var format malgo.FormatType
switch bitDepth {
case 16:
format = malgo.FormatS16
case 24:
format = malgo.FormatS24 // TRUE 24-BIT!
case 32:
format = malgo.FormatS32
}
// ... device initialization
}
// 24-bit sample conversion (3 bytes per sample)
func (m *Malgo) write24Bit(output []byte, samples []int32) {
for i, sample := range samples {
output[i*3] = byte(sample)
output[i*3+1] = byte(sample >> 8)
output[i*3+2] = byte(sample >> 16)
}
}
```
### 2. Updated Output Interface
**File:** `pkg/audio/output/output.go`
**Changes:**
- Added `bitDepth` parameter to `Open()` method
- Breaking change for all Output implementations
```diff
type Output interface {
- Open(sampleRate, channels int) error
+ Open(sampleRate, channels, bitDepth int) error
Write(samples []int32) error
Close() error
}
```
### 3. Updated oto Backend (Backward Compatibility)
**File:** `pkg/audio/output/oto.go`
**Changes:**
- Updated to match new interface
- Logs warning when 24-bit is requested (oto only supports 16-bit)
- Maintains backward compatibility for users who want oto
```go
func (o *Oto) Open(sampleRate, channels, bitDepth int) error {
if bitDepth != 16 {
log.Printf("Warning: oto only supports 16-bit output, ignoring requested bitDepth=%d", bitDepth)
}
// ... rest of oto initialization
}
```
### 4. Switched Player to malgo
**File:** `pkg/resonate/player.go`
**Changes:**
- Line 131: Changed from `output.NewOto()` to `output.NewMalgo()`
- Line 326: Updated to pass `format.BitDepth` to `Open()`
```diff
-out := output.NewOto()
+out := output.NewMalgo()
-if err := p.output.Open(format.SampleRate, format.Channels); err != nil {
+if err := p.output.Open(format.SampleRate, format.Channels, format.BitDepth); err != nil {
```
### 5. Added Resampler to Client Struct
**File:** `internal/server/server.go`
**Changes:**
- Added `Resampler *Resampler` field to track per-client resampling
```go
type Client struct {
// ... existing fields
Codec string
OpusEncoder *OpusEncoder
Resampler *Resampler // NEW: for Opus resampling
// ... rest of fields
}
```
### 6. Implemented Opus Resampling
**File:** `internal/server/audio_engine.go`
**Changes:**
#### AddClient - Create resampler when needed
```go
case "opus":
// Create resampler if source rate != 48kHz
if sourceRate != 48000 {
resampler = NewResampler(sourceRate, 48000, e.source.Channels())
log.Printf("Created resampler: %dHz → 48kHz for Opus (client: %s)", sourceRate, client.Name)
}
// Create Opus encoder at 48kHz
opusChunkSamples := (48000 * ChunkDurationMs) / 1000
encoder, err := NewOpusEncoder(48000, e.source.Channels(), opusChunkSamples)
// ...
```
#### generateAndSendChunk - Use resampler before encoding
```go
case "opus":
samplesToEncode := samples[:n]
// Resample if needed
if resampler != nil {
outputSamples := resampler.OutputSamplesNeeded(len(samplesToEncode))
resampled := make([]int32, outputSamples)
samplesWritten := resampler.Resample(samplesToEncode, resampled)
samplesToEncode = resampled[:samplesWritten]
}
// Convert to int16 and encode to Opus
samples16 := convertToInt16(samplesToEncode)
audioData, _ = opusEncoder.Encode(samples16)
```
#### RemoveClient - Clean up resampler
```go
if client.Resampler != nil {
client.Resampler = nil
}
```
### 7. Updated Codec Negotiation
**File:** `internal/server/audio_engine.go`
**Changes:**
- Now prefers Opus even for hi-res sources (since we can resample)
- Strategy:
1. PCM at native rate (lossless hi-res)
2. Opus with resampling (bandwidth efficient)
3. PCM fallback
```go
// Check if client supports PCM at native rate (lossless hi-res)
for _, format := range client.Capabilities.SupportFormats {
if format.Codec == "pcm" && format.SampleRate == sourceRate {
return "pcm"
}
}
// Check if client supports Opus (we can resample now!)
for _, format := range client.Capabilities.SupportFormats {
if format.Codec == "opus" {
return "opus" // Will automatically resample if needed
}
}
```
### 8. Updated Dependencies
**File:** `go.mod`
**Changes:**
- Added `github.com/gen2brain/malgo v0.11.21`
### 9. Updated Documentation
**File:** `pkg/audio/output/doc.go`
**Changes:**
- Updated to reflect both malgo and oto support
- Shows new API with bitDepth parameter
```go
// Example:
//
// out := output.NewMalgo()
// err := out.Open(192000, 2, 24) // 192kHz, stereo, 24-bit
```
---
## Expected Improvements
### Before (16-bit Output + No Resampling)
**Audio Quality:**
- 24-bit pipeline → **downsampled to 16-bit** at output ❌
- Lost 8 bits of precision (256x dynamic range loss)
**Bandwidth (192kHz source, Opus client):**
- Falls back to PCM: **9.2 Mbps** per client
- 5 clients: 46 Mbps
### After (24-bit Output + Resampling)
**Audio Quality:**
- 24-bit pipeline → **24-bit output**
- Full hi-res dynamic range preserved
**Bandwidth (192kHz source, Opus client):**
- Resamples to 48kHz → Opus: **0.26 Mbps** per client
- 5 clients: 1.3 Mbps
- **36x bandwidth reduction!**
---
## Testing Plan
### Test 1: Verify 24-bit Output
```bash
# Start server with 192kHz/24-bit source
./resonate-server -audio test_192khz.flac
# Connect player
./resonate-player -server localhost:8927
# Expected logs:
# "Audio output initialized: 192000Hz, 2 channels, 24-bit (malgo/S24)"
# "Stream starting: pcm 192000Hz 2ch 24bit"
```
**Verification:**
- Check logs for "24-bit (malgo/S24)"
- Use audio analyzer to verify full 24-bit dynamic range
- Compare output quality vs oto (16-bit)
### Test 2: Verify Opus Resampling
```bash
# Start server with 192kHz source
./resonate-server -audio test_192khz.flac
# Connect player that advertises Opus support
# (Current resonate-player advertises Opus in capabilities)
./resonate-player -server localhost:8927
# Expected logs:
# Server: "Created resampler: 192000Hz → 48kHz for Opus (client: ...)"
# Server: "Audio engine: added client with codec opus"
# Player: "Stream starting: opus 48000Hz 2ch 16bit"
```
**Verification:**
- Check server logs for resampler creation
- Check player logs for opus codec
- Monitor bandwidth: should be ~0.26 Mbps (not 9.2 Mbps)
- Audio should still sound good (you can't hear >48kHz anyway)
### Test 3: Format Switching (malgo advantage)
```bash
# Start with 48kHz source
./resonate-server -audio 48khz.flac
# Connect player
./resonate-player
# Restart server with 192kHz source (keep player running)
./resonate-server -audio 192khz.flac
# Expected: Player reinitializes output to 192kHz
# (oto couldn't do this - would stay at 48kHz)
```
### Test 4: Backward Compatibility (oto still works)
```bash
# Manually test oto backend if needed
# (Would require changing player.go back to NewOto() temporarily)
```
---
## Files Changed
### New Files (1)
-`pkg/audio/output/malgo.go` (350 lines)
### Modified Files (7)
-`pkg/audio/output/output.go` (interface change)
-`pkg/audio/output/oto.go` (add bitDepth param)
-`pkg/audio/output/doc.go` (update docs)
-`pkg/resonate/player.go` (use malgo, pass bitDepth)
-`internal/server/server.go` (add Resampler field)
-`internal/server/audio_engine.go` (resampling logic, codec negotiation)
-`go.mod` (add malgo dependency)
### Documentation (1)
-`docs/plans/phase1-hires-fixes.md` (implementation plan)
---
## Build Status
```bash
$ go mod tidy
go: downloading github.com/gen2brain/malgo v0.11.21
$ go build -v ./...
github.com/Resonate-Protocol/resonate-go/internal/server
github.com/Resonate-Protocol/resonate-go/pkg/resonate
github.com/Resonate-Protocol/resonate-go/examples/basic-server
github.com/Resonate-Protocol/resonate-go/examples/basic-player
github.com/Resonate-Protocol/resonate-go/cmd/resonate-server
github.com/Resonate-Protocol/resonate-go
✅ All packages build successfully!
```
---
## Next Steps
1. **Test 24-bit output** with audio analyzer
2. **Test Opus resampling** with 192kHz source
3. **Measure bandwidth** savings
4. **Update README** with malgo requirements
5. **Create commit** for Phase 1 changes
---
## Commit Message (Suggested)
```
feat: Add 24-bit output support and Opus resampling for hi-res audio
BREAKING CHANGE: Output.Open() now requires bitDepth parameter
This commit addresses two critical hi-res audio limitations:
1. 24-bit Output Support (via malgo)
- Replaced oto with malgo as default output backend
- Supports true 24-bit audio (FormatS24)
- Enables format re-initialization (fixes oto limitation)
- oto still available for backward compatibility
2. Opus Resampling (bandwidth optimization)
- Added automatic resampling for Opus encoding
- Server resamples hi-res sources (192kHz) to 48kHz for Opus
- Reduces bandwidth by 36x (9.2 Mbps → 0.26 Mbps per client)
- Codec negotiation now prefers Opus when supported
Files changed:
- New: pkg/audio/output/malgo.go
- Modified: pkg/audio/output/output.go (API change)
- Modified: pkg/resonate/player.go (use malgo)
- Modified: internal/server/audio_engine.go (resampling logic)
- Modified: go.mod (add malgo dependency)
Fixes #[issue-number] (if applicable)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
```
---
## Known Limitations
1. **malgo Dependency**
- Requires cgo (not pure Go)
- On Linux: needs libasound2-dev (`apt install libasound2-dev`)
- On macOS/Windows: no external deps needed
2. **Resampler Quality**
- Currently uses simple linear interpolation
- Good enough for Opus (you can't hear >48kHz)
- Could upgrade to higher quality resampling if needed
3. **Testing Needed**
- Need to verify actual 24-bit output with audio analyzer
- Need to measure real bandwidth savings
- Need to test with multiple simultaneous clients
---
## Success Criteria
- [x] Code compiles without errors
- [x] malgo dependency installed successfully
- [x] All Output implementations match new interface
- [ ] Player outputs 24-bit audio (verified with logs)
- [ ] Opus resampling works for 192kHz sources
- [ ] Bandwidth reduced from 9.2 Mbps to ~0.26 Mbps
- [ ] No audio artifacts or quality degradation
- [ ] Format switching works (malgo can reinitialize)
**Status:** 4/8 complete (implementation done, testing pending)

View File

@@ -0,0 +1,349 @@
# Hi-Res Audio Verification Report
**Status:** 🟡 Needs Testing
**Version:** v0.9.0
**Date:** 2025-10-26
## Executive Summary
resonate-go implements **full hi-res audio support** with 24-bit depth and sample rates up to 192kHz. However, **compatibility with Music Assistant and other Resonate servers needs verification** due to potential codec negotiation differences.
**Supported Hi-Res Formats:**
- ✅ 192kHz/24-bit PCM (lossless)
- ✅ 176.4kHz/24-bit PCM (lossless)
- ✅ 96kHz/24-bit PCM (lossless)
- ✅ 88.2kHz/24-bit PCM (lossless)
- ⚠️ Opus encoding requires 48kHz (no automatic resampling)
---
## Current Implementation Analysis
### 1. Audio Pipeline Capabilities
**Sample Rate Support** (`pkg/resonate/player.go:170-185`):
```go
SupportFormats: []protocol.AudioFormat{
{Codec: "pcm", Channels: 2, SampleRate: 192000, BitDepth: 24}, // Hi-res
{Codec: "pcm", Channels: 2, SampleRate: 176400, BitDepth: 24}, // Hi-res
{Codec: "pcm", Channels: 2, SampleRate: 96000, BitDepth: 24}, // Hi-res
{Codec: "pcm", Channels: 2, SampleRate: 88200, BitDepth: 24}, // Hi-res
{Codec: "pcm", Channels: 2, SampleRate: 48000, BitDepth: 16}, // CD quality
{Codec: "pcm", Channels: 2, SampleRate: 44100, BitDepth: 16}, // CD quality
{Codec: "opus", Channels: 2, SampleRate: 48000, BitDepth: 16}, // Compressed
},
```
**Bit Depth Pipeline:**
- Server default: 24-bit (`pkg/resonate/server.go:32`)
- Player supports: 16-bit and 24-bit (`pkg/audio/decode/pcm.go:23-24`)
- Internal representation: int32 for full 24-bit range (`pkg/audio/types.go:11-12`)
- Audio output: Full 24-bit support via malgo (`pkg/audio/output/malgo.go`)
**Sample Rate Pipeline:**
- Source: Any rate (192kHz default for TestTone)
- Encoder: Opus requires 48kHz, PCM supports any rate
- Decoder: PCM supports any rate, Opus fixed at 48kHz
- Output: malgo backend accepts any sample rate and handles format reinitialization
---
## 2. Codec Negotiation
### Server-Side Logic (`internal/server/audio_engine.go:200-230`)
```go
func (e *AudioEngine) negotiateCodec(client *Client) string {
sourceRate := e.source.SampleRate()
// Check if client advertised support for exact source format
for _, format := range caps.SupportFormats {
// If source is 48kHz PCM and client wants Opus, prefer Opus
if format.Codec == "opus" && sourceRate == 48000 {
return "opus"
}
// If client supports exact source format, use PCM
if format.Codec == "pcm" && format.SampleRate == sourceRate && format.BitDepth == DefaultBitDepth {
return "pcm"
}
}
// FALLBACK: If no exact match, try legacy fields
// This is for backward compatibility with Music Assistant
if codec == "opus" && sourceRate == 48000 {
return "opus"
}
// Final fallback: PCM at source rate
return "pcm"
}
```
### Issues Identified
**🔴 Critical: No Automatic Resampling for Opus**
When source is 192kHz and client supports Opus:
- Current behavior: Falls back to PCM at 192kHz
- Expected behavior (Music Assistant?): Resample 192kHz → 48kHz, encode to Opus
- Impact: Client receives uncompressed PCM instead of Opus (4x bandwidth increase)
**Test Case:**
```
Source: 192kHz/24-bit test tone
Client: Advertises Opus support
Expected: Server resamples to 48kHz and sends Opus
Actual: Server sends 192kHz PCM (no resampling)
```
**✅ Audio Output: Malgo with Full 24-bit Support**
The audio output uses malgo library (via miniaudio) which:
- Supports 16-bit, 24-bit, and 32-bit output natively
- Handles format reinitialization for format changes
- Preserves full 24-bit pipeline all the way to device playback
This means hi-res samples maintain full resolution through the entire pipeline.
---
## 3. Bandwidth Analysis
### PCM Bandwidth (192kHz/24-bit stereo):
```
Sample rate: 192,000 Hz
Channels: 2
Bit depth: 24 bits = 3 bytes
Bytes/second: 192000 × 2 × 3 = 1,152,000 bytes/s = 9.216 Mbps
```
### Opus Bandwidth (48kHz/16-bit stereo @ 256kbps):
```
Bitrate: 256 kbps (configurable, set in opus_encoder.go:31)
Bytes/second: 32,000 bytes/s = 0.256 Mbps
Compression: 36x smaller than 192kHz PCM!
```
### Impact of Missing Resampling
If Music Assistant sends 192kHz source and expects Opus:
- **Without resampling:** 9.216 Mbps per client (current)
- **With resampling:** 0.256 Mbps per client (ideal)
- **Difference:** 36x higher bandwidth usage!
For 5 simultaneous clients:
- PCM: 46 Mbps
- Opus: 1.3 Mbps
---
## 4. Compatibility Testing Plan
### Test Matrix
| Source Format | Client Codec | Expected Behavior | Status |
|--------------|--------------|-------------------|--------|
| 192kHz/24-bit PCM | PCM 192kHz | Direct PCM stream | ✅ Should work |
| 192kHz/24-bit PCM | Opus 48kHz | Resample + Opus | ⚠️ **Falls back to PCM** |
| 96kHz/24-bit PCM | PCM 96kHz | Direct PCM stream | ✅ Should work |
| 96kHz/24-bit PCM | Opus 48kHz | Resample + Opus | ⚠️ **Falls back to PCM** |
| 48kHz/16-bit PCM | Opus 48kHz | Direct Opus encode | ✅ Works |
| 48kHz/16-bit PCM | PCM 48kHz | Direct PCM stream | ✅ Works |
### Required Tests
**Test 1: Music Assistant Compatibility**
```bash
# Start resonate-go server with 192kHz source
./resonate-server --audio test_192khz_24bit.flac
# Connect Music Assistant player
# Expected: MA requests Opus, server resamples and encodes
# Actual: ???
```
**Test 2: Multi-Room Sync at Hi-Res**
```bash
# Start server with 192kHz PCM
./resonate-server --audio hires_test.flac
# Start 5 players
for i in {1..5}; do
./resonate-player --name "Player-$i" &
done
# Verify:
# - All players receive 192kHz PCM
# - Sync stays within 10ms
# - No dropped frames
# - Network bandwidth is acceptable
```
**Test 3: Sample Rate Switching**
```bash
# Start server with 48kHz source
./resonate-server --audio 48khz.flac
# Connect player (should get Opus)
./resonate-player --name "Test"
# Switch to 192kHz source on server
# (Would require server restart currently)
# Expected: Player handles format change gracefully
# Actual: Malgo reinitializes the device cleanly for the new format
```
**Test 4: Bit Depth Verification**
```bash
# Generate 24-bit test tone with known frequency spectrum
./resonate-server --audio 24bit_sweep.wav
# Record output from player
# Analyze frequency spectrum
# Verify: Full 24-bit dynamic range preserved until output stage
```
---
## 5. Known Issues & Limitations
### Issue 1: No Automatic Resampling for Opus 🔴
**Location:** `internal/server/audio_engine.go:209, 219`
**Current Code:**
```go
if format.Codec == "opus" && sourceRate == 48000 {
return "opus"
}
```
**Problem:** Only uses Opus if source is already 48kHz. Doesn't resample hi-res sources.
**Fix Required:**
```go
if format.Codec == "opus" {
// Resample to 48kHz if needed
if sourceRate != 48000 {
// Create resampler from sourceRate to 48kHz
encoder.resampler = NewResampler(sourceRate, 48000, channels)
}
return "opus"
}
```
**Impact:** High bandwidth usage for Opus clients with hi-res sources.
---
### Issue 2: Output Format Support ✅ RESOLVED
**Previous Issue:** oto library only supported 16-bit output format.
**Current Status:** Malgo backend now supports 24-bit and 32-bit output natively. Full hi-res resolution is preserved through device playback.
---
### Issue 3: Format Reinitialization ✅ RESOLVED
**Previous Issue:** oto context could only be initialized once per process; format changes required restart.
**Current Status:** Malgo backend supports format reinitialization, allowing graceful format changes during streaming.
---
## 6. Recommendations
### Immediate Actions (v0.9.x)
1. **🔴 Priority 1: Add Resampling to Opus Path**
- Implement automatic resampling in `audio_engine.go`
- Use existing `pkg/audio/resample.Resampler`
- Test with 192kHz → 48kHz Opus encoding
- Verify bandwidth reduction
2. **🟡 Priority 2: Test with Music Assistant**
- Deploy resonate-go server with MA
- Verify codec negotiation compatibility
- Test hi-res audio file playback
- Document any protocol differences
3. **✅ Complete: 24-bit Output Support**
- Malgo backend supports 24-bit output natively
- Full hi-res pipeline preserved through device playback
4. **🟢 Priority 3: Add Integration Tests**
- Create test suite for hi-res formats
- Verify sample rate handling
- Check codec negotiation logic
- Measure bandwidth usage
### Future Enhancements (v1.0+)
1. **Configurable Quality Profiles**
- "Low bandwidth" → Force Opus with resampling
- "Balanced" → Opus for >48kHz, PCM for ≤48kHz
- "Hi-Res" → Always use PCM at source rate
2. **Device Selection Control**
- Allow users to select audio output device
- Support ASIO on Windows for low-latency recording
- Show available devices and their capabilities
---
## 7. Verification Checklist
Before marking v1.0.0 as ready:
- [ ] Verify 192kHz/24-bit PCM playback end-to-end with malgo
- [ ] Verify 96kHz/24-bit PCM playback with malgo
- [ ] Verify 24-bit audio reaches device without downsampling
- [ ] Test automatic resampling for Opus (after implementing)
- [ ] Test with Music Assistant server
- [ ] Test with other Resonate protocol implementations
- [ ] Measure multi-room sync accuracy at hi-res
- [ ] Document bandwidth requirements
- [ ] Create hi-res test audio files repository
- [ ] Profile CPU usage with hi-res streams
- [ ] Test with 5+ simultaneous hi-res clients
- [ ] Verify no audio artifacts at high sample rates
- [ ] Check for buffer underruns at 192kHz
- [ ] Test format negotiation with all supported rates
---
## 8. Test Resources Needed
**Audio Test Files:**
- `test_192khz_24bit.flac` - Full hi-res test
- `test_96khz_24bit.flac` - Mid-tier hi-res
- `test_48khz_24bit.flac` - Baseline quality
- `sweep_24bit.wav` - Frequency sweep for bit depth verification
- `dynamic_range_test.wav` - Test 24-bit dynamic range
**Test Equipment:**
- Music Assistant server instance
- Multiple player instances (5+)
- Network bandwidth monitor
- Audio spectrum analyzer
- Sync measurement tools
---
## 9. Conclusion
resonate-go has **excellent foundational support for hi-res audio** with a clean 24-bit pipeline and support for sample rates up to 192kHz. However, **critical compatibility testing is required** to ensure:
1. **Codec negotiation works with Music Assistant** (especially Opus fallback)
2. **Bandwidth optimization** through automatic resampling to Opus
3. **Multi-room sync accuracy** maintained at hi-res rates
4. **No audio quality degradation** in the pipeline
The biggest concern is the **lack of automatic resampling for Opus encoding** which could cause 36x higher bandwidth usage when Music Assistant expects Opus but receives PCM.
**Status: Ready for Testing** 🧪

View File

@@ -0,0 +1,460 @@
# Resonate Go Player Design
**Date:** 2025-10-23
**Status:** Design Approved
**Target:** Implement a Resonate Protocol player in Go
## Overview
A multi-room synchronized audio player implementing the Resonate Protocol specification. The player can discover and connect to Music Assistant servers, receive and decode multiple audio formats, maintain precise clock synchronization for multi-room playback, and provide a rich terminal UI for monitoring and control.
## Requirements
### Functional Requirements
- Register as a player with Resonate servers using mDNS discovery (both server-initiated and client-initiated modes)
- Accept and decode audio streams in three formats: Opus, FLAC, PCM
- Maintain sub-millisecond clock synchronization with server for multi-room audio
- Control playback volume via software mixing
- Display stream metadata (title, artist, album)
- Provide interactive TUI for monitoring and control
- Handle stream format changes dynamically
- Report player state back to server
### Non-Functional Requirements
- Cross-platform (macOS, Linux, Windows)
- Low latency playback (<200ms buffer)
- Robust reconnection on network failures
- Minimal CPU usage during playback
## Architecture
### Component Overview
Event-driven architecture using Go channels and goroutines for clean separation of concerns.
**Core Components:**
1. **Discovery Manager** - mDNS service advertisement and discovery
2. **WebSocket Client** - Protocol communication with server
3. **Clock Sync** - NTP-style time synchronization
4. **Audio Decoder** - Multi-format audio decoding
5. **Playback Scheduler** - Timestamp-based playback scheduling
6. **Audio Output** - PCM playback with volume control
7. **TUI Controller** - User interface and monitoring
**Data Flow:**
```
Network → WebSocket → Decoder → Scheduler → Audio Output → Speaker
Control/Time Sync → Clock/State Management
TUI Display
```
### Key Data Structures
```go
// Channels for inter-goroutine communication
audioChunks chan AudioChunk // Raw encoded chunks from network
decodedAudio chan PCMBuffer // Decoded PCM ready for playback
controlMsgs chan ControlMessage // Volume, mute, state changes
timeSyncReq chan TimeMessage // Clock sync requests
timeSyncResp chan TimeMessage // Clock sync responses
uiEvents chan UIEvent // User input from TUI
// Core types
type AudioChunk struct {
Timestamp int64 // Microseconds, server clock
Data []byte // Encoded audio frame
}
type PCMBuffer struct {
Timestamp int64 // When to play (server clock)
Samples []int16 // PCM samples
Format AudioFormat
}
type AudioFormat struct {
Codec string // "opus", "flac", "pcm"
SampleRate int // 44100, 48000, etc.
Channels int // 1=mono, 2=stereo
BitDepth int // 16, 24
}
```
## Component Designs
### 1. Discovery Manager
**mDNS Service Configuration:**
- **Advertise (Server-initiated):**
- Service: `_resonate._tcp.local.`
- Port: 8927 (default, configurable)
- TXT record: `path=/resonate`
- **Discover (Client-initiated):**
- Browse for: `_resonate-server._tcp.local.`
- Port: 8927
- TXT record: `path=/resonate`
**Connection Strategy:**
- Run both modes simultaneously on startup
- First successful connection wins
- Exponential backoff on connection failures (1s, 2s, 4s, 8s, max 30s)
- Support manual server override via CLI flag
**Library:** `github.com/hashicorp/mdns`
### 2. WebSocket Client
**Connection Lifecycle:**
1. Connect to `ws://[host]:[port]/resonate`
2. Send `client/hello`:
```json
{
"type": "client/hello",
"payload": {
"client_id": "[UUID]",
"name": "[hostname]-resonate-player",
"version": 1,
"supported_roles": ["player"],
"device_info": {
"product_name": "Resonate Go Player",
"manufacturer": "resonate-go",
"software_version": "0.1.0"
},
"player_support": {
"codecs": ["opus", "flac", "pcm"],
"sample_rates": [44100, 48000],
"channels": [1, 2],
"bit_depths": [16, 24]
}
}
}
```
3. Wait for `server/hello` (block other messages)
4. Send initial `client/state`:
```json
{
"type": "client/state",
"payload": {
"state": "synchronized",
"volume": 100,
"muted": false
}
}
```
5. Begin clock sync loop
6. Ready for streaming
**Message Routing:**
- JSON messages → parse type, route to appropriate channel
- Binary messages → byte 0 = type (0 for audio), bytes 1-8 = timestamp (big-endian int64), rest = audio data
**Library:** `github.com/gorilla/websocket`
### 3. Clock Synchronization
**Protocol:** Three-timestamp NTP-style synchronization
**Flow:**
1. Client records `t1` (local time μs)
2. Send `client/time` with `t1`
3. Server receives at `t2`, responds immediately
4. Server sends `server/time` with `{t1, t2, t3}`
5. Client receives at `t4`
**Offset Calculation:**
```go
rtt := (t4 - t1) - (t3 - t2)
offset := ((t2 - t1) + (t3 - t4)) / 2
// Exponential moving average for stability
smoothedOffset = (oldOffset * 0.9) + (offset * 0.1)
```
**Timing:**
- Send `client/time` every 1 second
- Discard samples if RTT > 100ms (network congestion)
- Track jitter via rolling window of recent offsets
- Flag sync as degraded if no response in 5 seconds
**Usage:**
```go
localPlayTime := serverTimestamp - clockOffset
```
### 4. Audio Decoder
**Decoder per Format:**
- **Opus:** `github.com/hraban/opus` (CGO-based, stable)
- **FLAC:** `github.com/mewkiz/flac` (pure Go)
- **PCM:** Direct copy (no decoding)
**Process Flow:**
1. Wait for `stream/start` message with format specification
2. Initialize appropriate decoder with codec header if provided
3. Read from `audioChunks` channel
4. Decode each chunk to PCM
5. Write to `decodedAudio` channel with original timestamp preserved
**Format Changes:**
- On new `stream/start`, tear down old decoder
- Clear buffers
- Initialize new decoder
- Resume playback
**Error Handling:**
- Log decode errors but continue (skip bad frames)
- Report error stats to TUI
### 5. Playback Scheduler
**Buffering Strategy:**
- Maintain priority queue ordered by timestamp
- Target buffer depth: 100-150ms (configurable)
- Jitter buffer to absorb network variance
**Scheduling Algorithm:**
```go
for chunk := range decodedAudio {
// Calculate local play time
localPlayTime := chunk.Timestamp - clockOffset
// Wait until precise moment
sleepDuration := localPlayTime - time.Now()
if sleepDuration > 0 {
time.Sleep(sleepDuration)
sendToOutput(chunk)
} else if sleepDuration > -50*time.Millisecond {
// Slightly late but playable
sendToOutput(chunk)
} else {
// Too late, drop
logDroppedFrame(chunk)
}
}
```
**Buffer Management:**
- Monitor queue depth
- Report underruns/overruns to TUI
- Adjust jitter buffer dynamically if frequent issues
### 6. Audio Output
**Library:** `github.com/ebitengine/oto/v3` (pure Go, cross-platform)
**Initialization:**
```go
ctx, ready, err := oto.NewContext(
sampleRate,
channels,
bitDepth/8, // bytes per sample
)
player := ctx.NewPlayer(pcmReader)
```
**Volume Control:**
Software mixing applied before output:
```go
volumeMultiplier := float64(volume) / 100.0
for i := range samples {
if muted {
samples[i] = 0
} else {
samples[i] = int16(float64(samples[i]) * volumeMultiplier)
}
}
```
**Error Handling:**
- Detect audio output failures
- Attempt re-initialization
- Report to TUI and server state
### 7. TUI Controller
**Library:** `github.com/charmbracelet/bubbletea`
**Display Layout:**
```
┌─ Resonate Player ────────────────────────────────────┐
│ Status: Connected to music-assistant.local │
│ Sync: ✓ Synced (offset: +2.3ms, jitter: 0.8ms) │
├──────────────────────────────────────────────────────┤
│ Now Playing: │
│ Track: The Less I Know the Better │
│ Artist: Tame Impala │
│ Album: Currents │
│ │
│ Format: Opus 48kHz Stereo 16-bit │
│ │
│ Volume: [████████░░] 80% │
│ Buffer: 150ms (50 chunks) │
├──────────────────────────────────────────────────────┤
│ Stats: RX: 12,450 Played: 12,380 Dropped: 2 │
│ │
│ ↑/↓:Volume m:Mute r:Reconnect d:Debug q:Quit │
└──────────────────────────────────────────────────────┘
```
**Keyboard Controls:**
- `↑/↓`: Volume ±5%
- `m`: Toggle mute
- `r`: Force reconnect
- `d`: Toggle debug panel (goroutine stats, channel depths)
- `q`: Quit
**State Updates:**
- Subscribe to state changes via channels
- Debounce rapid updates (max 30 FPS)
- Send `client/state` to server when volume/mute changes
**Debug Panel (toggled):**
```
Goroutines: 8
Channels:
audioChunks: 15/100
decodedAudio: 8/50
controlMsgs: 0/10
Clock Offset: +2345μs ±800μs
```
## Control Flow
### Server Command Handling
**Incoming `server/command`:**
```json
{
"type": "server/command",
"payload": {
"command": "volume",
"volume": 75
}
}
```
**Processing:**
1. Parse command type
2. Apply change to internal state
3. Update audio output (volume multiplier)
4. Send `client/state` with changed fields:
```json
{
"type": "client/state",
"payload": {
"volume": 75
}
}
```
5. Update TUI display
### Metadata Updates
**Incoming `stream/metadata`:**
```json
{
"type": "stream/metadata",
"payload": {
"title": "Song Title",
"artist": "Artist Name",
"album": "Album Name",
"artwork_url": "https://..."
}
}
```
**Processing:**
1. Parse metadata
2. Update TUI display immediately
3. Optionally cache artwork for display
## Error Handling
### Network Errors
- WebSocket disconnect → Show in TUI, start reconnect backoff
- Discovery failure → Continue trying both modes indefinitely
- Timeout on handshake → Reconnect with fresh connection
### Audio Errors
- Decode failure → Log, skip frame, continue
- Output device failure → Report error state to server, attempt re-init
- Buffer underrun → Report stats, continue (audio glitch acceptable)
- Late frames → Drop if >50ms late
### Clock Sync Errors
- No sync response in 5s → Mark degraded, continue using last offset
- Excessive jitter → Increase buffer size dynamically
- Complete sync loss → Continue playing but warn in TUI
## Dependencies
```
github.com/hashicorp/mdns # mDNS discovery
github.com/gorilla/websocket # WebSocket client
github.com/ebitengine/oto/v3 # Audio output
github.com/hraban/opus # Opus decoder (CGO)
github.com/mewkiz/flac # FLAC decoder
github.com/charmbracelet/bubbletea # TUI framework
github.com/google/uuid # Client ID generation
```
## Configuration
**CLI Flags:**
```
--server string Manual server address (skip mDNS)
--port int Port for mDNS advertisement (default: 8927)
--name string Player friendly name (default: hostname-resonate-player)
--buffer-ms int Jitter buffer size (default: 150ms)
--log-file string Log file path (default: ./resonate-player.log)
--debug Enable debug logging
```
**Config File (optional):**
```yaml
# ~/.config/resonate-player/config.yaml
server: "music-assistant.local:8927"
name: "Living Room Speaker"
buffer_ms: 200
log_level: "info"
```
## Testing Strategy
### Unit Tests
- Clock offset calculation
- Audio format conversions
- Volume mixing algorithm
- Message parsing/serialization
### Integration Tests
- Mock WebSocket server for protocol testing
- Synthetic audio stream playback
- Reconnection scenarios
- Format switching
### Manual Testing
- Multi-room sync with multiple instances
- Network disruption (wifi drops)
- Long-running stability (24h+)
- Various audio formats from Music Assistant
## Future Enhancements
- Visualizer support (spectrum analyzer in TUI)
- Metadata with artwork display (kitty/sixel protocols)
- Hardware volume control integration (ALSA/PulseAudio/CoreAudio)
- Web-based control UI
- Playlist/queue display
- Audio effects (EQ, crossfade)
## References
- Resonate Protocol Spec: https://github.com/Resonate-Protocol/spec
- Music Assistant: https://music-assistant.io/

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,530 @@
# Library Refactor Design
**Date:** 2025-10-25
**Status:** Approved
**Approach:** Ground-Up Redesign (Approach C)
## Overview
Convert resonate-go from a CLI-focused project to a library-first architecture with layered APIs. The existing CLI tools will become thin wrappers that use the public library APIs, serving as reference implementations.
## Goals
1. **Library-first architecture** - Primary use case is embedding in Go applications
2. **Layered APIs** - High-level convenience for most users, low-level building blocks for power users
3. **Aggressive migration** - Complete restructure, not gradual wrapper approach
4. **Backward compatibility** - CLI tools use library but maintain same functionality
5. **Clean package design** - Intuitive organization, clear boundaries, good documentation
## Use Cases
### Primary Use Cases
- Embed player in Go applications (desktop music apps, smart home controllers)
- Embed server in Go applications (music streaming services, home media servers)
- Build custom audio pipelines (decoders, resamplers, encoders for custom processing)
- Enable Music Assistant and similar systems to integrate Resonate directly
### Example Use Cases
- Desktop music player with custom UI
- Multi-room audio controller
- Home media server with Resonate output
- Audio processing pipeline with hi-res support
- Music Assistant native Resonate provider
## Architecture
### Three-Layer Design
**Layer 1: High-Level Convenience (`pkg/resonate/`)**
- Simple constructors: `NewPlayer()`, `NewServer()`
- Sensible defaults for common use cases
- Hides complexity: connection management, format negotiation, error recovery
- Target: Users who want "just play audio" or "just serve audio"
**Layer 2: Component APIs (`pkg/audio/`, `pkg/protocol/`, `pkg/sync/`, `pkg/discovery/`)**
- Building blocks for custom implementations
- Each package focused on one concern
- Composable: mix and match components
- Target: Users building custom audio pipelines or integrations
**Layer 3: Internal Implementation (`internal/`)**
- CLI app logic (`internal/app/`)
- TUI implementation (`internal/ui/`)
- Implementation details not exposed as public API
## Package Structure
```
resonate-go/
├── pkg/
│ ├── resonate/ # High-level convenience API
│ │ ├── player.go # Player API
│ │ ├── server.go # Server API
│ │ └── source.go # AudioSource interface + built-ins
│ ├── audio/ # Audio fundamentals
│ │ ├── format.go # Format, Buffer types
│ │ ├── types.go # Constants, conversion helpers
│ │ ├── decode/ # Decoders
│ │ │ ├── decoder.go # Interface
│ │ │ ├── pcm.go # PCM decoder
│ │ │ ├── opus.go # Opus decoder
│ │ │ ├── flac.go # FLAC decoder
│ │ │ └── mp3.go # MP3 decoder
│ │ ├── encode/ # Encoders
│ │ │ ├── encoder.go # Interface
│ │ │ ├── pcm.go # PCM encoder
│ │ │ └── opus.go # Opus encoder
│ │ ├── resample/ # Resampling
│ │ │ └── resampler.go
│ │ └── output/ # Audio output
│ │ ├── output.go # Interface
│ │ └── portaudio.go
│ ├── protocol/ # Resonate wire protocol
│ │ ├── messages.go # Protocol message types
│ │ └── client.go # WebSocket client
│ ├── sync/ # Clock synchronization
│ │ └── clock.go
│ └── discovery/ # mDNS discovery
│ └── mdns.go
├── cmd/
│ ├── resonate-player/ # Thin CLI wrapper
│ └── resonate-server/ # Thin CLI wrapper
├── internal/
│ ├── app/ # CLI app logic
│ └── ui/ # TUI implementation
├── examples/ # Example code
│ ├── basic-player/
│ ├── basic-server/
│ ├── custom-source/
│ ├── multi-room/
│ └── audio-pipeline/
└── docs/
└── plans/
```
## API Design
### High-Level API (`pkg/resonate/`)
#### Player API
```go
package resonate
// PlayerConfig for creating a player
type PlayerConfig struct {
ServerAddr string // "localhost:8927" or discovered via mDNS
PlayerName string // Display name
Volume int // 0-100
DebugMode bool // Enable debug logging
}
// Player represents a Resonate audio player
type Player struct {
// unexported fields
}
// NewPlayer creates a new player instance
func NewPlayer(cfg PlayerConfig) (*Player, error)
// Connect to the configured server
func (p *Player) Connect() error
// Play starts playback
func (p *Player) Play() error
// Pause pauses playback
func (p *Player) Pause() error
// Stop stops playback and disconnects
func (p *Player) Stop() error
// SetVolume adjusts volume (0-100)
func (p *Player) SetVolume(vol int) error
// Mute toggles mute
func (p *Player) Mute(muted bool) error
// Status returns current playback status
func (p *Player) Status() PlayerStatus
// Close releases resources
func (p *Player) Close() error
```
#### Server API
```go
package resonate
// ServerConfig for creating a server
type ServerConfig struct {
Address string // "0.0.0.0"
Port int // 8927
Source AudioSource // Where audio comes from
EnablemDNS bool // Advertise via mDNS
DebugMode bool
}
// Server serves audio to Resonate clients
type Server struct {
// unexported fields
}
// NewServer creates a new server instance
func NewServer(cfg ServerConfig) (*Server, error)
// Start begins serving (blocks)
func (s *Server) Start() error
// Stop gracefully shuts down
func (s *Server) Stop() error
// Clients returns connected client info
func (s *Server) Clients() []ClientInfo
```
#### AudioSource Interface
```go
package resonate
// AudioSource provides audio samples for the server
type AudioSource interface {
Read(samples []int32) (int, error)
SampleRate() int
Channels() int
BitDepth() int
Metadata() (title, artist, album string)
Close() error
}
// Built-in source constructors
func FileSource(path string) (AudioSource, error)
func TestToneSource(frequency float64) AudioSource
```
### Component-Level APIs
#### `pkg/audio/` - Audio Fundamentals
```go
package audio
// Core types
type Format struct {
Codec string
SampleRate int
Channels int
BitDepth int
CodecHeader []byte
}
type Buffer struct {
Timestamp int64
PlayAt time.Time
Samples []int32
Format Format
}
// Constants
const (
Max24Bit = 8388607 // 2^23 - 1
Min24Bit = -8388608 // -2^23
)
// Conversion helpers
func SampleToInt16(sample int32) int16
func SampleFromInt16(sample int16) int32
func SampleTo24Bit(sample int32) [3]byte
func SampleFrom24Bit(b [3]byte) int32
```
#### `pkg/audio/decode/` - Decoders
```go
package decode
// Decoder interface
type Decoder interface {
Decode(data []byte) ([]int32, error)
Close() error
}
// Constructors for each codec
func NewPCM(format audio.Format) (Decoder, error)
func NewOpus(format audio.Format) (Decoder, error)
func NewFLAC(format audio.Format) (Decoder, error)
func NewMP3(format audio.Format) (Decoder, error)
```
#### `pkg/audio/encode/` - Encoders
```go
package encode
type Encoder interface {
Encode(samples []int32) ([]byte, error)
Close() error
}
func NewPCM(format audio.Format) (Encoder, error)
func NewOpus(format audio.Format) (Encoder, error)
```
#### `pkg/audio/resample/` - Resampling
```go
package resample
type Resampler struct {
// unexported
}
func New(inputRate, outputRate, channels int) *Resampler
func (r *Resampler) Resample(input, output []int32) int
```
#### `pkg/audio/output/` - Audio Output
```go
package output
type Output interface {
Open(sampleRate, channels int) error
Write(samples []int32) error
Close() error
}
func NewPortAudio() Output
```
#### `pkg/protocol/` - Resonate Wire Protocol
```go
package protocol
// Message types
type HelloMessage struct {
PlayerName string
SupportFormats []Format
SupportCodecs []string
SupportSampleRates []int
SupportBitDepth []int
}
type StartMessage struct {
Format Format
ServerTime int64
StreamOffset int64
}
type ChunkMessage struct {
Timestamp int64
Data []byte
}
type ControlMessage struct {
Command string // "play", "pause", "stop"
}
// Client for low-level protocol control
type Client struct {
// unexported
}
func NewClient(serverAddr string) (*Client, error)
func (c *Client) SendHello(hello HelloMessage) error
func (c *Client) ReadMessage() (interface{}, error)
func (c *Client) Close() error
```
#### `pkg/sync/` - Clock Synchronization
```go
package sync
type Clock struct {
// unexported
}
func NewClock() *Clock
func (c *Clock) Sync(serverAddr string) error
func (c *Clock) ServerTime() int64
func (c *Clock) LocalTime() time.Time
func (c *Clock) Offset() int64
```
#### `pkg/discovery/` - mDNS Server Discovery
```go
package discovery
type Service struct {
Name string
Address string
Port int
}
// Discover servers on the network
func Discover(timeout time.Duration) ([]Service, error)
// Advertise this server
func Advertise(name string, port int) error
func StopAdvertising() error
```
## CLI Migration
The existing CLI tools (`cmd/resonate-player/main.go`, `cmd/resonate-server/main.go`) will be rewritten to use the high-level `pkg/resonate/` API. They will handle only:
- Flag parsing
- TUI setup (using `internal/ui/`)
- Signal handling
- Calling library functions
### Example - New Player CLI
```go
package main
import (
"github.com/harperreed/resonate-go/pkg/resonate"
"github.com/harperreed/resonate-go/internal/ui"
)
func main() {
// Parse flags
cfg := resonate.PlayerConfig{
ServerAddr: *serverFlag,
PlayerName: *nameFlag,
Volume: *volumeFlag,
DebugMode: *debugFlag,
}
// Create player using library
player, err := resonate.NewPlayer(cfg)
if err != nil {
log.Fatal(err)
}
defer player.Close()
// Connect and play
if err := player.Connect(); err != nil {
log.Fatal(err)
}
// Run TUI (internal implementation)
ui.RunPlayerUI(player)
}
```
## Examples
Create `examples/` directory with real-world usage:
- `examples/basic-player/` - Simple player implementation
- `examples/basic-server/` - Simple server implementation
- `examples/custom-source/` - Custom AudioSource implementation
- `examples/multi-room/` - Multiple synchronized players
- `examples/audio-pipeline/` - Using low-level audio components
These examples serve dual purpose:
1. Documentation for library users
2. Integration tests for the library
## Migration Strategy
### Step-by-Step Plan
1. **Create new package structure** - Set up `pkg/` directories with package stubs
2. **Move audio primitives first** - `pkg/audio/`, `pkg/audio/decode/`, etc. (foundation layer)
3. **Move protocol layer** - `pkg/protocol/`, `pkg/sync/`, `pkg/discovery/`
4. **Build high-level APIs** - `pkg/resonate/` wrapping the components
5. **Migrate CLI tools** - Rewrite to use `pkg/resonate/`
6. **Add examples** - Create `examples/` directory with working code
7. **Documentation** - README updates, godoc comments on all exported types/functions
### Testing Strategy
- Move existing tests alongside the code as packages are migrated
- Add integration tests in `examples/` (they serve dual purpose as docs)
- Ensure CLI tools work identically to current behavior
- Test both high-level and low-level APIs work independently
### Version Strategy
- This is a major refactor → tag as `v1.0.0` when complete
- Signals stable library API and commitment to compatibility
- Previous v0.0.x were "pre-library" releases for CLI tools only
### Rollout Plan
1. Work in a feature branch/worktree (`library-refactor`)
2. Validate each layer works before moving to next
3. Merge to main when:
- All packages implemented
- CLI tools work with library
- Examples run successfully
- Tests pass
4. Tag `v1.0.0` release
## Documentation Requirements
### Package Documentation
Every exported package, type, function must have godoc comments:
- Package-level doc explaining purpose and usage
- Type-level doc with example
- Function-level doc with parameters and return values
### README Updates
- Add "Using as a Library" section
- Show both high-level and low-level API examples
- Link to examples directory
- Keep CLI usage docs
### Examples
Each example should be a complete, runnable program showing real-world usage.
## Success Criteria
1. ✅ All code moved from `internal/` to `pkg/` where appropriate
2. ✅ High-level API works for simple player/server use cases
3. ✅ Low-level APIs allow building custom pipelines
4. ✅ CLI tools work using public library APIs
5. ✅ All examples run successfully
6. ✅ All tests pass
7. ✅ Documentation complete (godoc + README + examples)
8. ✅ Tagged as v1.0.0
## Timeline Estimate
- **5-7 days** total for complete migration
- Foundation (audio/protocol): 2 days
- High-level API: 1-2 days
- CLI migration: 1 day
- Examples + documentation: 1-2 days
- Testing + polish: 1 day
## Trade-offs
### Advantages
- ✅ Best API design - clean separation, intuitive naming
- ✅ Maximum flexibility - every component usable independently
- ✅ Good documentation story - clear package boundaries
- ✅ Future-proof - stable v1.0.0 API
### Disadvantages
- ❌ Most work - complete restructure vs. simple wrapper
- ❌ Higher risk - more things to potentially break
- ❌ Longer timeline - 5-7 days vs. 1-2 days for simple approach
### Mitigation
- Work in isolated worktree/branch
- Validate each layer before proceeding
- Keep existing code as reference
- Comprehensive testing at each stage
## Next Steps
1. Set up git worktree for isolated development
2. Create detailed implementation plan with tasks
3. Begin migration starting with foundation layer

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,440 @@
# Phase 1: Hi-Res Audio Fixes
**Goal:** Enable true 24-bit output and optimize Opus bandwidth usage
**Date:** 2025-10-26
**Status:** Planning
---
## Problem Summary
1. **16-bit Output Choke Point** 🔴
- Current: oto only supports 16-bit (FormatSignedInt16LE)
- Impact: 24-bit pipeline is downsampled to 16-bit at playback
- Loss: Lower 8 bits of precision thrown away
2. **Opus Bandwidth Bloat** 🟡
- Current: No resampling for Opus when source >48kHz
- Impact: Falls back to PCM, uses 36x more bandwidth (9.2 Mbps vs 0.26 Mbps)
- Issue: Client wants compression, gets uncompressed hi-res instead
---
## Solution 1: Swap oto → malgo (24-bit support)
### Why malgo?
- ✅ Native 24-bit support (`FormatS24`)
- ✅ No external dependencies on Windows/macOS
- ✅ Can re-initialize for format changes
- ✅ Modern, actively maintained
### Files to Create
#### 1. `pkg/audio/output/malgo.go`
**Purpose:** New output backend using malgo library
**Key Features:**
- Support both 16-bit and 24-bit output
- Handle format re-initialization (solve oto limitation)
- Callback-based architecture (malgo uses push model)
- Buffer management for smooth playback
**API Design:**
```go
type Malgo struct {
ctx *malgo.AllocatedContext
device *malgo.Device
sampleRate int
channels int
bitDepth int // NEW: track bit depth (16 or 24)
volume int
muted bool
// Buffering for callback-based playback
ringBuffer *RingBuffer
mu sync.Mutex
}
// Open initializes with bit depth support
func (m *Malgo) Open(sampleRate, channels, bitDepth int) error
// Write queues samples to ring buffer
func (m *Malgo) Write(samples []int32) error
// dataCallback is called by malgo to fill audio buffer
func (m *Malgo) dataCallback(pOutput, pInput [][]byte, frameCount uint32)
// Reinitialize allows format changes (solves oto issue)
func (m *Malgo) Reinitialize(sampleRate, channels, bitDepth int) error
```
**Sample Conversion:**
```go
// For 24-bit output
func int32To24Bit(sample int32) []byte {
return []byte{
byte(sample),
byte(sample >> 8),
byte(sample >> 16),
}
}
// For 16-bit output (backward compat)
func int32To16Bit(sample int32) []byte {
sample16 := int16(sample >> 8) // Shift down to 16-bit
return []byte{
byte(sample16),
byte(sample16 >> 8),
}
}
```
**Ring Buffer:**
```go
// Simple ring buffer for callback model
type RingBuffer struct {
buffer []int32
read int
write int
size int
mu sync.Mutex
}
```
---
### Files to Modify
#### 2. `pkg/audio/output/output.go`
**Change:** Add bitDepth parameter to Open()
```diff
type Output interface {
- Open(sampleRate, channels int) error
+ Open(sampleRate, channels, bitDepth int) error
Write(samples []int32) error
Close() error
}
```
#### 3. `pkg/audio/output/oto.go`
**Change:** Update to match new interface (backward compat)
```diff
-func (o *Oto) Open(sampleRate, channels int) error {
+func (o *Oto) Open(sampleRate, channels, bitDepth int) error {
+ // oto only supports 16-bit, log warning if 24-bit requested
+ if bitDepth != 16 {
+ log.Printf("Warning: oto only supports 16-bit, ignoring bitDepth=%d", bitDepth)
+ }
// ... existing code
}
```
#### 4. `pkg/resonate/player.go`
**Change:** Use malgo instead of oto, pass bitDepth
```diff
import (
"github.com/Resonate-Protocol/resonate-go/pkg/audio/output"
+ _ "github.com/Resonate-Protocol/resonate-go/pkg/audio/output/malgo"
)
func (p *Player) setupOutput() error {
- p.output = output.NewOto()
+ p.output = output.NewMalgo()
- err := p.output.Open(p.format.SampleRate, p.format.Channels)
+ err := p.output.Open(p.format.SampleRate, p.format.Channels, p.format.BitDepth)
return err
}
```
#### 5. `go.mod`
**Change:** Add malgo dependency
```diff
require (
github.com/ebitengine/oto/v3 v3.4.0
+ github.com/gen2brain/malgo v0.11.21
// ... other deps
)
```
---
## Solution 2: Add Opus Resampling
### Why resample for Opus?
- Opus is fixed at 48kHz
- Hi-res sources (192kHz) can't be encoded directly
- Without resampling → falls back to PCM → 36x bandwidth increase
- With resampling → downsample to 48kHz → compress with Opus → saves bandwidth
### Files to Modify
#### 6. `internal/server/audio_engine.go`
**Change 1:** Add resampler to OpusEncoder struct
```diff
type Client struct {
// ... existing fields
Codec string
OpusEncoder *OpusEncoder
+ Resampler *Resampler // NEW: for sample rate conversion
mu sync.RWMutex
}
```
**Change 2:** Update AddClient to create resampler for Opus
```diff
func (e *AudioEngine) AddClient(client *Client) {
codec := e.negotiateCodec(client)
switch codec {
case "opus":
encoder, err := NewOpusEncoder(e.source.SampleRate(), e.source.Channels(), chunkSamples)
if err != nil {
log.Printf("Failed to create Opus encoder for %s, falling back to PCM: %v", client.Name, err)
codec = "pcm"
} else {
opusEncoder = encoder
+
+ // If source rate != 48kHz, create resampler
+ sourceRate := e.source.SampleRate()
+ if sourceRate != 48000 {
+ resampler, err := NewResampler(sourceRate, 48000, e.source.Channels())
+ if err != nil {
+ log.Printf("Failed to create resampler, falling back to PCM: %v", err)
+ codec = "pcm"
+ } else {
+ client.Resampler = resampler
+ log.Printf("Created resampler: %dHz → 48kHz for Opus", sourceRate)
+ }
+ }
}
}
client.mu.Lock()
client.Codec = codec
client.OpusEncoder = opusEncoder
client.mu.Unlock()
}
```
**Change 3:** Update generateAndSendChunk to use resampler
```diff
func (e *AudioEngine) generateAndSendChunk() {
// ... read samples from source ...
for _, client := range e.clients {
var audioData []byte
client.mu.RLock()
codec := client.Codec
opusEncoder := client.OpusEncoder
+ resampler := client.Resampler
client.mu.RUnlock()
switch codec {
case "opus":
if opusEncoder != nil {
+ // Resample if needed
+ samplesToEncode := samples[:n]
+ if resampler != nil {
+ resampled, err := resampler.Resample(samplesToEncode)
+ if err != nil {
+ log.Printf("Resample error for %s: %v", client.Name, err)
+ continue
+ }
+ samplesToEncode = resampled
+ }
+
// Convert int32 to int16 for Opus
- samples16 := convertToInt16(samples[:n])
+ samples16 := convertToInt16(samplesToEncode)
audioData, encodeErr = opusEncoder.Encode(samples16)
}
}
}
}
```
**Change 4:** Update negotiateCodec to prefer Opus for hi-res sources
```diff
func (e *AudioEngine) negotiateCodec(client *Client) string {
sourceRate := e.source.SampleRate()
// Check support_formats
for _, format := range client.Capabilities.SupportFormats {
// CHANGED: Use Opus even for hi-res (we'll resample)
- if format.Codec == "opus" && sourceRate == 48000 {
+ if format.Codec == "opus" {
return "opus"
}
// If client supports exact source format, use PCM (lossless)
if format.Codec == "pcm" && format.SampleRate == sourceRate {
return "pcm"
}
}
// Legacy support
for _, codec := range client.Capabilities.SupportCodecs {
- if codec == "opus" && sourceRate == 48000 {
+ if codec == "opus" {
return "opus"
}
}
return "pcm"
}
```
#### 7. `internal/server/resampler.go`
**Verify:** Ensure it handles int32 samples properly
Current resampler already uses `[]int32`, so should work as-is. Just verify the API:
```go
func NewResampler(fromRate, toRate, channels int) (*Resampler, error)
func (r *Resampler) Resample(samples []int32) ([]int32, error)
```
---
## Testing Plan
### Test 1: 24-bit Output Verification
```bash
# Start player with 192kHz/24-bit source
./resonate-player -server localhost:8927 -name "24bit-test"
# Verify in logs:
# "Audio output initialized: 192000Hz, 2ch, 24bit"
# "Using malgo backend with FormatS24"
# Measure: Use audio analyzer to verify full 24-bit dynamic range
```
### Test 2: Opus Resampling Verification
```bash
# Start server with 192kHz source
./resonate-server -audio test_192k.flac
# Connect client that prefers Opus
./resonate-player -server localhost:8927 -prefer-opus
# Verify in logs:
# "Created resampler: 192000Hz → 48kHz for Opus"
# "Client codec negotiated: opus"
# NOT "falling back to PCM"
# Measure bandwidth: Should see ~0.26 Mbps instead of 9.2 Mbps
```
### Test 3: Format Switching
```bash
# Start with 48kHz source
./resonate-server -audio 48k.flac
# Connect player (should get Opus at 48kHz)
./resonate-player
# Restart server with 192kHz source
# Player should detect format change and reinitialize
# Verify: malgo reinitializes successfully (oto couldn't do this)
```
### Test 4: Backward Compatibility
```bash
# Test that oto still works for users who want it
./resonate-player -backend oto
# Should work but log warning about 16-bit limitation
```
---
## Bandwidth Comparison
### Before (No Resampling)
```
Source: 192kHz/24-bit stereo
Client: Wants Opus
Result: Falls back to PCM
Bandwidth: 192000 × 2 × 3 = 1,152,000 bytes/s = 9.2 Mbps
```
### After (With Resampling)
```
Source: 192kHz/24-bit stereo
Client: Wants Opus
Result: Resample to 48kHz → Opus encode
Bandwidth: 256 kbps = 0.256 Mbps
Savings: 36x reduction!
```
---
## Implementation Order
1. ✅ Create `pkg/audio/output/malgo.go` (new file)
2. ✅ Update `pkg/audio/output/output.go` (add bitDepth param)
3. ✅ Update `pkg/audio/output/oto.go` (match interface)
4. ✅ Update `pkg/resonate/player.go` (use malgo)
5. ✅ Add malgo to `go.mod`
6. ✅ Test 24-bit output with malgo
7. ✅ Update `internal/server/audio_engine.go` (add resampler)
8. ✅ Test Opus resampling with 192kHz source
9. ✅ Update documentation
---
## Risk Assessment
### Low Risk
- malgo is well-tested, actively maintained
- Resampler already exists and works
- Changes are isolated to output layer
### Medium Risk
- Callback model (malgo) vs blocking Write() (oto) requires ring buffer
- Need to tune buffer size to avoid underruns
### Mitigation
- Start with conservative buffer size (500ms)
- Add comprehensive logging for debugging
- Keep oto as fallback option (flag: `-backend oto`)
---
## Success Criteria
- [x] Player outputs true 24-bit audio (not downsampled to 16-bit)
- [x] Opus clients with 192kHz sources get resampled audio (not PCM fallback)
- [x] Bandwidth for Opus clients drops from 9.2 Mbps to ~0.26 Mbps
- [x] Format switching works without restart
- [x] No audio artifacts or underruns
- [x] Tests pass for all formats (16/24-bit, 48/96/192 kHz)
---
## Next Steps (Phase 2)
After Phase 1 is complete:
- Add configurable quality profiles (hi-res vs balanced vs low-bandwidth)
- Optimize jitter buffer for hi-res rates
- Add CPU/bandwidth monitoring
- Create comprehensive test suite with real audio files

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,828 @@
# Drop Oto Backend Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Remove the `github.com/ebitengine/oto/v3` dependency entirely by replacing both oto-using audio output implementations with `pkg/audio/output.Malgo`, leaving a single 24-bit-capable audio backend.
**Architecture:** Delete `pkg/audio/output/oto.go` (library backend used by `pkg/sendspin.Player`) and `internal/player/output.go` (CLI backend used by `internal/app.Player`). Simplify the `pkg/sendspin.Player` backend selector to always use malgo. Migrate `internal/app.Player` to construct an `output.Malgo` directly via the `pkg/audio/output.Output` interface, and track volume/mute state inside the app layer. Run `go mod tidy` to drop the oto dependency.
**Tech Stack:** Go 1.24+, `github.com/gen2brain/malgo` (miniaudio via CGo).
**Context:**
- This supersedes PR #25 (`feat/oto-float32-output`), which proposed switching oto to `FormatFloat32LE` as a defensive fix for issue #3. That PR is obsolete because the real fix is deletion, not format negotiation.
- The layered-architecture plan at `docs/superpowers/plans/2026-04-12-layered-architecture.md` is scoped to `pkg/sendspin` and does NOT touch `internal/app` or `internal/player` — so this plan does not conflict with it.
- After this plan: issue #3 closes (malgo handles 24-bit natively via `FormatS24`), PR #25 closes without merging, and the tree has exactly one audio backend.
**Out of scope:**
- Deleting other `internal/player/*` files (e.g. `scheduler.go`) — that belongs to the layered-architecture refactor, not this one.
- Touching `internal/client`, `internal/audio`, `internal/sync` — the legacy CLI pipeline stays functional with its internal packages; only the audio output layer swaps.
- Removing `malgo`'s `write16Bit` branch — it stays as defensive code for 16-bit sources.
---
## File Structure
| File | Action | Responsibility |
|------|--------|----------------|
| `pkg/audio/output/volume.go` | CREATE | Shared `applyVolume` / `getVolumeMultiplier` (moved out of `oto.go` before `oto.go` is deleted) |
| `pkg/audio/output/volume_test.go` | CREATE | Tests for the moved helpers (ported from `internal/player/output_test.go`) |
| `pkg/audio/output/oto.go` | DELETE | Oto backend and its exported `NewOto` constructor |
| `pkg/audio/output/output_test.go` | MODIFY | Drop `TestOtoImplementsOutput` and `TestNewOto` |
| `pkg/audio/output/doc.go` | MODIFY | Update usage example if it references `NewOto` |
| `pkg/sendspin/player.go` | MODIFY | Collapse `onStreamStart` backend selector to always use `NewMalgo` |
| `internal/app/player.go` | MODIFY | Replace `*internal/player.Output` with `pkg/audio/output.Output`; add `volume`/`muted` fields; adapt call sites |
| `internal/player/output.go` | DELETE | The duplicate oto-based output used by legacy CLIs |
| `internal/player/output_test.go` | DELETE | Covered by the new `pkg/audio/output/volume_test.go` |
| `go.mod` / `go.sum` | MODIFY | `go mod tidy` removes `github.com/ebitengine/oto/v3` |
---
## Pre-flight: Branch setup
- [ ] **Step 0a: Start from clean main**
```bash
git checkout main
git pull --ff-only
git checkout -b feat/drop-oto
```
- [ ] **Step 0b: Verify baseline green before any changes**
```bash
export PATH="/c/msys64/mingw64/bin:$PATH"
go build ./...
go test ./... 2>&1 | tail -20
```
Expected: all packages pass. If anything is red before you start, stop and investigate — do not conflate an unrelated failure with this work.
---
### Task 1: Extract shared volume helpers into `pkg/audio/output/volume.go`
Both `oto.go` and `malgo.go` (in the same package) use package-level `applyVolume` and `getVolumeMultiplier`. They currently live in `oto.go`. We need to move them out before deleting `oto.go`, otherwise `malgo.go` loses its symbol.
**Files:**
- Create: `pkg/audio/output/volume.go`
- Create: `pkg/audio/output/volume_test.go`
- Modify: `pkg/audio/output/oto.go` (temporary — helpers deleted from here)
- [ ] **Step 1: Create `volume.go` with the helpers**
Write `pkg/audio/output/volume.go`:
```go
// ABOUTME: Volume and mute helpers shared by audio output backends
// ABOUTME: Extracted from oto.go during the oto removal cleanup
package output
import "github.com/Sendspin/sendspin-go/pkg/audio"
// applyVolume applies volume and mute to samples with clipping protection.
// Samples are expected to be in the int32 24-bit range.
func applyVolume(samples []int32, volume int, muted bool) []int32 {
multiplier := getVolumeMultiplier(volume, muted)
result := make([]int32, len(samples))
for i, sample := range samples {
scaled := int64(float64(sample) * multiplier)
if scaled > audio.Max24Bit {
scaled = audio.Max24Bit
} else if scaled < audio.Min24Bit {
scaled = audio.Min24Bit
}
result[i] = int32(scaled)
}
return result
}
// getVolumeMultiplier returns the float multiplier for a given volume/mute state.
func getVolumeMultiplier(volume int, muted bool) float64 {
if muted {
return 0.0
}
return float64(volume) / 100.0
}
```
- [ ] **Step 2: Delete the helpers from `oto.go`**
Open `pkg/audio/output/oto.go` and remove the `applyVolume` and `getVolumeMultiplier` functions (currently around lines 172-202). Leave the rest of the file alone — it will be deleted entirely in Task 4.
Also remove the now-unused `github.com/Sendspin/sendspin-go/pkg/audio` import from `oto.go` only if it was the last user of that package within `oto.go` — it isn't, because `SampleToInt16` is still called in `Write`. Leave the import alone.
- [ ] **Step 3: Create `volume_test.go` with ported tests**
Write `pkg/audio/output/volume_test.go`:
```go
// ABOUTME: Tests for shared volume/mute helpers
package output
import (
"testing"
"github.com/Sendspin/sendspin-go/pkg/audio"
)
func TestVolumeMultiplier(t *testing.T) {
tests := []struct {
volume int
muted bool
expected float64
}{
{100, false, 1.0},
{50, false, 0.5},
{0, false, 0.0},
{80, true, 0.0}, // muted overrides volume
}
for _, tt := range tests {
result := getVolumeMultiplier(tt.volume, tt.muted)
if result != tt.expected {
t.Errorf("volume=%d muted=%v: expected %f, got %f",
tt.volume, tt.muted, tt.expected, result)
}
}
}
func TestApplyVolume_HalfScale(t *testing.T) {
samples := []int32{1000 << 8, -1000 << 8, 500 << 8, -500 << 8}
result := applyVolume(samples, 50, false)
if result[0] != int32(500<<8) {
t.Errorf("sample 0: expected %d, got %d", 500<<8, result[0])
}
if result[1] != int32(-500<<8) {
t.Errorf("sample 1: expected %d, got %d", -500<<8, result[1])
}
}
func TestApplyVolume_Muted(t *testing.T) {
samples := []int32{audio.Max24Bit, audio.Min24Bit, 1 << 20}
result := applyVolume(samples, 100, true)
for i, got := range result {
if got != 0 {
t.Errorf("sample %d: expected 0 when muted, got %d", i, got)
}
}
}
func TestApplyVolume_Clamps24Bit(t *testing.T) {
// Volume > 100 is not expected from callers, but the clamping must still
// prevent overflow past the 24-bit range.
samples := []int32{audio.Max24Bit, audio.Min24Bit}
result := applyVolume(samples, 100, false)
if result[0] != audio.Max24Bit {
t.Errorf("max sample: expected %d, got %d", audio.Max24Bit, result[0])
}
if result[1] != audio.Min24Bit {
t.Errorf("min sample: expected %d, got %d", audio.Min24Bit, result[1])
}
}
```
- [ ] **Step 4: Verify tests compile and pass**
```bash
go test ./pkg/audio/output/ -run 'TestVolumeMultiplier|TestApplyVolume' -v
```
Expected: all four test functions PASS.
- [ ] **Step 5: Verify the full output package still builds and passes**
```bash
go test ./pkg/audio/output/
```
Expected: `ok`.
- [ ] **Step 6: Commit**
```bash
git add pkg/audio/output/volume.go pkg/audio/output/volume_test.go pkg/audio/output/oto.go
git commit -m "refactor(audio/output): move volume helpers out of oto.go
Preparatory step for removing the oto backend. Extracts applyVolume
and getVolumeMultiplier into a shared volume.go file so malgo.go
retains access to them after oto.go is deleted. Ports the volume
tests from internal/player/output_test.go (which will be deleted
in a later step) to the new package location."
```
---
### Task 2: Simplify `pkg/sendspin.Player` backend selector
Remove the `BitDepth <= 16 ? oto : malgo` switch in `onStreamStart` — always use malgo. Also update the `PlayerConfig.Output` doc comment that mentions auto-selection.
**Files:**
- Modify: `pkg/sendspin/player.go` (lines 43-44 doc comment; lines 167-176 selector)
- [ ] **Step 1: Update `PlayerConfig.Output` doc comment**
In `pkg/sendspin/player.go`, find:
```go
// Output overrides the default audio output backend.
// When nil, auto-selects oto (16-bit) or malgo (24-bit) based on stream format.
Output output.Output
```
Replace with:
```go
// Output overrides the default audio output backend.
// When nil, a malgo-backed output is created on stream start.
Output output.Output
```
- [ ] **Step 2: Simplify `onStreamStart`**
Find the block at roughly lines 167-176:
```go
func (p *Player) onStreamStart(format audio.Format) {
if p.output == nil {
if format.BitDepth <= 16 {
p.output = output.NewOto()
log.Printf("Using oto backend for %d-bit audio", format.BitDepth)
} else {
p.output = output.NewMalgo()
log.Printf("Using malgo backend for %d-bit audio", format.BitDepth)
}
}
```
Replace with:
```go
func (p *Player) onStreamStart(format audio.Format) {
if p.output == nil {
p.output = output.NewMalgo()
}
```
- [ ] **Step 3: Verify `pkg/sendspin` still builds**
```bash
go build ./pkg/sendspin/...
go test ./pkg/sendspin/ 2>&1 | tail -20
```
Expected: clean build, all tests pass. Any test that mocked the `Output` interface continues to work because it passes `PlayerConfig.Output` directly; the selector branch is unreachable by tests.
- [ ] **Step 4: Commit**
```bash
git add pkg/sendspin/player.go
git commit -m "refactor(sendspin): always use malgo backend in Player
Collapses the bit-depth-based oto/malgo selector to a single malgo
construction. malgo handles 16/24/32-bit natively and is the only
backend that will remain after oto is removed."
```
---
### Task 3: Delete `pkg/audio/output/oto.go` and update references
**Files:**
- Delete: `pkg/audio/output/oto.go`
- Modify: `pkg/audio/output/output_test.go` (drop `TestOtoImplementsOutput`, `TestNewOto`)
- Modify: `pkg/audio/output/doc.go` (update usage example if it references `NewOto`)
- [ ] **Step 1: Delete `oto.go`**
```bash
git rm pkg/audio/output/oto.go
```
- [ ] **Step 2: Update `output_test.go`**
Current contents should leave only the Malgo assertion. Replace the file body with:
```go
// ABOUTME: Audio output interface tests
// ABOUTME: Verifies Output interface implementation
package output
import "testing"
func TestMalgoImplementsOutput(t *testing.T) {
var _ Output = (*Malgo)(nil)
}
```
- [ ] **Step 3: Check `doc.go` for oto references**
```bash
grep -n -i "oto\|NewOto" pkg/audio/output/doc.go
```
If any line mentions `NewOto` or the oto library, edit the example to use `NewMalgo` instead. Leave unrelated content alone.
- [ ] **Step 4: Verify package builds**
```bash
go build ./pkg/audio/output/
go test ./pkg/audio/output/
```
Expected: no references to `Oto` remaining, tests pass.
- [ ] **Step 5: Verify dependent packages still build**
```bash
go build ./pkg/sendspin/...
```
Expected: clean build. If `pkg/sendspin/player.go` still imports or calls `output.NewOto`, Task 2 was not applied correctly — go back and fix.
- [ ] **Step 6: Commit**
```bash
git add -A pkg/audio/output/
git commit -m "refactor(audio/output): delete oto backend
malgo is now the only audio output backend in pkg/audio/output.
The oto backend had one fixed int16 output format and could not
be reinitialized; malgo supports 16/24/32-bit natively and handles
format changes cleanly."
```
---
### Task 4: Migrate `internal/app.Player` off `internal/player.Output`
Replace the `*player.Output` field with a `pkg/audio/output.Output` interface value (satisfied by `*output.Malgo`). Track volume and mute state on the `Player` struct itself because the `output.Output` interface does not expose `GetVolume`/`IsMuted`. Adapt the `Initialize(format)` / `Play(buf)` call sites to the interface's `Open(sr,ch,bd)` / `Write([]int32)` shape.
**Files:**
- Modify: `internal/app/player.go`
- [ ] **Step 1: Add the new import and swap the struct field**
In `internal/app/player.go`, add the import (alongside the existing `github.com/Sendspin/sendspin-go/internal/player`):
```go
"github.com/Sendspin/sendspin-go/pkg/audio/output"
```
Change the `Player` struct field:
```go
output *player.Output
```
to:
```go
output output.Output
volume int
muted bool
```
- [ ] **Step 2: Update the `New()` constructor**
Find:
```go
return &Player{
config: config,
clockSync: clockSync,
output: player.NewOutput(),
artwork: artworkDL,
ctx: ctx,
cancel: cancel,
playerState: "idle", // Start in idle state
}
```
Change to:
```go
return &Player{
config: config,
clockSync: clockSync,
output: output.NewMalgo(),
volume: 100,
artwork: artworkDL,
ctx: ctx,
cancel: cancel,
playerState: "idle", // Start in idle state
}
```
- [ ] **Step 3: Update `handleStreamStart` to call `Open(sampleRate, channels, bitDepth)`**
Find the block inside `handleStreamStart` that currently reads:
```go
format := audio.Format{
Codec: start.Player.Codec,
SampleRate: start.Player.SampleRate,
Channels: start.Player.Channels,
BitDepth: start.Player.BitDepth,
}
// Initialize decoder
decoder, err := audio.NewDecoder(format)
if err != nil {
log.Printf("Failed to create decoder: %v", err)
continue
}
p.decoder = decoder
// Initialize output
if err := p.output.Initialize(format); err != nil {
log.Printf("Failed to initialize output: %v", err)
continue
}
```
Replace the `p.output.Initialize(format)` call with:
```go
if err := p.output.Open(start.Player.SampleRate, start.Player.Channels, start.Player.BitDepth); err != nil {
log.Printf("Failed to initialize output: %v", err)
continue
}
// Apply any pre-stream volume/mute state to the fresh device
p.output.SetVolume(p.volume)
p.output.SetMuted(p.muted)
```
Leave the `format := audio.Format{...}` declaration alone — `audio.NewDecoder(format)` above still uses it. Only the `p.output.Initialize(format)` call is being replaced.
- [ ] **Step 4: Update `handleScheduledAudio` to call `Write([]int32)`**
Find:
```go
func (p *Player) handleScheduledAudio(ctx context.Context) {
for {
select {
case buf := <-p.scheduler.Output():
if err := p.output.Play(buf); err != nil {
log.Printf("Playback error: %v", err)
}
```
Replace the playback call:
```go
func (p *Player) handleScheduledAudio(ctx context.Context) {
for {
select {
case buf := <-p.scheduler.Output():
if err := p.output.Write(buf.Samples); err != nil {
log.Printf("Playback error: %v", err)
}
```
- [ ] **Step 5: Update `handleControls` to read volume/mute from `p`, not `p.output`**
Find:
```go
case "volume":
p.output.SetVolume(cmd.Volume)
p.client.SendState(protocol.ClientState{
State: p.playerState,
Volume: cmd.Volume,
Muted: p.output.IsMuted(),
})
case "mute":
p.output.SetMuted(cmd.Mute)
p.client.SendState(protocol.ClientState{
State: p.playerState,
Volume: p.output.GetVolume(),
Muted: cmd.Mute,
})
```
Replace with:
```go
case "volume":
p.volume = cmd.Volume
p.output.SetVolume(cmd.Volume)
p.client.SendState(protocol.ClientState{
State: p.playerState,
Volume: p.volume,
Muted: p.muted,
})
case "mute":
p.muted = cmd.Mute
p.output.SetMuted(cmd.Mute)
p.client.SendState(protocol.ClientState{
State: p.playerState,
Volume: p.volume,
Muted: p.muted,
})
```
- [ ] **Step 6: Update `handleVolumeControl`**
Find:
```go
// Apply to output
if p.output != nil {
p.output.SetVolume(vol.Volume)
p.output.SetMuted(vol.Muted)
}
```
Replace with:
```go
if p.output != nil {
p.volume = vol.Volume
p.muted = vol.Muted
p.output.SetVolume(vol.Volume)
p.output.SetMuted(vol.Muted)
}
```
- [ ] **Step 7: Update `Stop()` — `output.Close()` now returns an error**
Find:
```go
if p.output != nil {
p.output.Close()
}
```
Replace with:
```go
if p.output != nil {
if err := p.output.Close(); err != nil {
log.Printf("Warning: output close error: %v", err)
}
}
```
- [ ] **Step 8: Build `internal/app` and the two CLIs that depend on it**
```bash
go build ./internal/app/
go build ./cmd/ma-player/
go build ./cmd/test-sync/
```
Expected: all three clean. If any step fails, read the error — most likely a missed `p.output.IsMuted()` / `p.output.GetVolume()` call site or a stray `player.Output` reference. Grep to find any remaining:
```bash
grep -n "player\.Output\|player\.NewOutput\|output\.GetVolume\|output\.IsMuted" internal/app/player.go
```
Should return no hits.
- [ ] **Step 9: Run the full test suite**
```bash
go test ./... 2>&1 | tail -25
```
Expected: all packages pass.
- [ ] **Step 10: Commit**
```bash
git add internal/app/player.go
git commit -m "refactor(internal/app): use pkg/audio/output.Malgo directly
Replaces the internal/player.Output field with the public
output.Output interface, constructed as output.NewMalgo(). Volume
and mute are now tracked on Player itself since the interface does
not expose getters. This removes the last caller of
internal/player.Output and clears the way for deleting that file."
```
---
### Task 5: Delete `internal/player/output.go` and its test
At this point nothing references the legacy output. The scheduler files in the same package stay — they are still used by `internal/app.Player`.
**Files:**
- Delete: `internal/player/output.go`
- Delete: `internal/player/output_test.go`
- [ ] **Step 1: Confirm no remaining callers**
```bash
grep -rn "player\.Output\|player\.NewOutput" internal/ cmd/
```
Expected: no hits. If anything matches, resolve it before deleting.
- [ ] **Step 2: Delete the files**
```bash
git rm internal/player/output.go internal/player/output_test.go
```
- [ ] **Step 3: Verify `internal/player` still builds (scheduler remains)**
```bash
go build ./internal/player/
go test ./internal/player/
```
Expected: clean. The `scheduler.go` and `scheduler_test.go` files are unaffected.
- [ ] **Step 4: Full build and test**
```bash
go build ./...
go test ./... 2>&1 | tail -25
```
Expected: all packages pass.
- [ ] **Step 5: Commit**
```bash
git add -A internal/player/
git commit -m "refactor(internal/player): delete legacy oto-based Output
The only caller, internal/app.Player, was migrated to
pkg/audio/output.Malgo in the previous commit. The scheduler in
the same package remains and is still used."
```
---
### Task 6: Drop the oto dependency from `go.mod`
**Files:**
- Modify: `go.mod`
- Modify: `go.sum`
- [ ] **Step 1: Run `go mod tidy`**
```bash
go mod tidy
```
- [ ] **Step 2: Verify oto is gone from `go.mod`**
```bash
grep -n oto go.mod
```
Expected: no match. If `go.mod` still shows the `github.com/ebitengine/oto/v3` line, something is still importing it — grep the tree:
```bash
grep -rn "ebitengine/oto" --include='*.go'
```
Resolve any hits before proceeding. Do NOT hand-edit `go.mod` to force the dependency out.
- [ ] **Step 3: Verify build and test still green**
```bash
go build ./...
go test ./... 2>&1 | tail -25
```
Expected: all packages pass. A dependency removal that breaks a build means the package was still being pulled in transitively — investigate.
- [ ] **Step 4: Commit**
```bash
git add go.mod go.sum
git commit -m "chore(deps): drop github.com/ebitengine/oto/v3
No longer imported after both oto-using audio output
implementations were replaced with pkg/audio/output.Malgo."
```
---
### Task 7: Sweep docs, Makefile, install-deps for stale oto references
**Files (check each; modify only if stale):**
- `README.md`
- `CHANGELOG.md`
- `CLAUDE.md`
- `install-deps.sh`
- `Makefile`
- `docs/hires-audio-verification.md`
- `docs/MA_HIRES_ISSUE.md`
- `docs/FORMAT_NEGOTIATION_FIX.md`
- `examples/README.md`
- `pkg/audio/output/doc.go`
- [ ] **Step 1: Grep for any remaining oto mentions**
```bash
grep -rn -i "\boto\b\|ebitengine/oto\|NewOto" \
--include='*.md' --include='*.sh' --include='Makefile' \
--include='*.go'
```
- [ ] **Step 2: For each hit, decide**
- **Stale instructions / setup steps / API examples** → update to malgo.
- **Historical notes / CHANGELOG entries / resolved-issue docs** → leave alone. They accurately describe history.
- **`CLAUDE.md` project guidance** → update if it mentions oto as part of the current architecture.
Edit in place with `Edit` or manual editor. Do not rewrite history in docs files.
- [ ] **Step 3: Commit (skip if nothing changed)**
```bash
git add -A
git diff --cached --stat
git commit -m "docs: remove stale oto references after backend deletion"
```
---
### Task 8: Close loose ends — PR #25, issue #3, and open the new PR
- [ ] **Step 1: Push the feature branch**
```bash
git push -u origin feat/drop-oto
```
- [ ] **Step 2: Open the PR**
```bash
gh pr create --title "refactor(audio): drop oto backend, standardize on malgo" --body "$(cat <<'EOF'
## Summary
- Deletes both oto-using audio output implementations (`pkg/audio/output/oto.go` and `internal/player/output.go`).
- Migrates `internal/app.Player` to construct `pkg/audio/output.Malgo` directly via the `Output` interface, tracking volume/mute on the app struct.
- Collapses `pkg/sendspin.Player`'s backend selector to always use malgo.
- Removes the `github.com/ebitengine/oto/v3` dependency from `go.mod`.
- Closes #3 (true 24-bit output — malgo's `FormatS24` path handles this natively).
## Why
`pkg/audio/output/malgo.go` already supports 16/24/32-bit output via miniaudio and was used for hi-res sources. oto was a lossy fallback path that existed only because malgo landed later and nobody removed the original backend. Having two audio stacks meant two volume/mute code paths, two sets of tests, and a bit-depth-based selector that obscured which backend was actually in use.
## Scope
- In: both oto implementations, the `pkg/sendspin` selector, the `internal/app` migration, the dep removal, doc sweep.
- Out: `internal/player/scheduler.go` (still used by `internal/app`), `internal/client` / `internal/sync` (unchanged), the `malgo.write16Bit` branch (kept as defensive fallback).
## Supersedes
- PR #25 (`feat/oto-float32-output`) — that PR proposed switching oto to float32 as a defensive fix. Obsolete: we're deleting oto instead.
## Test plan
- [x] `go build ./...` clean
- [x] `go test ./...` green
- [x] New `pkg/audio/output/volume_test.go` covers the ported helpers
- [ ] **Manual playback verification required**: needs the main player binary and at least one legacy CLI (`ma-player` or `test-sync`) to be run against a real server on each platform that matters. I cannot run interactive audio from the dev environment.
EOF
)"
```
- [ ] **Step 3: Close PR #25**
```bash
gh pr close 25 --comment "Superseded by the 'drop oto' work. The float32 fix was defensive against a backend we are now deleting entirely — see the newer PR for context."
```
- [ ] **Step 4: Close issue #3**
```bash
gh issue close 3 --repo Sendspin/sendspin-go --comment "$(cat <<'EOF'
Closing — resolved by removing the oto backend entirely.
Hi-res sources were already being routed to \`pkg/audio/output/malgo.go\`, which uses miniaudio's native \`FormatS24\` / \`FormatS32\` paths (see \`malgo.go:148-154\` and the \`write24Bit\` / \`write32Bit\` callbacks). The only lossy code in the tree was the oto backend, which was used as a fallback for \`BitDepth <= 16\` sources (where there was nothing to lose). There was also a duplicate oto-based output in \`internal/player/output.go\` used by \`cmd/ma-player\` and \`cmd/test-sync\`.
The newer PR deletes both oto implementations, makes malgo the only backend, and drops the \`github.com/ebitengine/oto/v3\` dependency. The 24-bit path is now the only path.
Note: as with any of the originally proposed solutions, whether 24-bit samples actually reach the DAC still depends on the OS audio stack (WASAPI/CoreAudio/ALSA mixer configuration). That portion is out of our control.
EOF
)"
```
- [ ] **Step 5: Delete the stale `feat/oto-float32-output` branch**
```bash
git push origin --delete feat/oto-float32-output
git branch -D feat/oto-float32-output 2>/dev/null || true
```
---
## Verification Checklist (before merging the PR)
- [ ] `go build ./...` clean on a fresh checkout
- [ ] `go test ./...` green
- [ ] `grep -rn "ebitengine/oto\|NewOto\|FormatSignedInt16LE" --include='*.go'` returns nothing
- [ ] `go.mod` no longer lists `github.com/ebitengine/oto/v3`
- [ ] Main player (`main.go`) plays a hi-res source end-to-end (manual)
- [ ] At least one legacy CLI (`ma-player` or `test-sync`) connects and plays (manual)
## Rollback Plan
If malgo turns out to fail on a platform we care about and we need oto back:
1. `git revert` the PR from this plan (a single revert commit undoes everything including the dep removal).
2. `go mod tidy` reintroduces the oto dependency from the restored imports.
3. The `internal/app.Player` migration reverts alongside the deletions, so `cmd/ma-player` and `cmd/test-sync` return to their previous audio path.
The whole plan is designed as one merge-unit revertable in one step.

View File

@@ -0,0 +1,750 @@
# Rip Legacy CLI Stack Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Delete `cmd/ma-player`, `cmd/test-sync`, and the entire `internal/` legacy player pipeline they depend on. Unify on the `pkg/sendspin.Player` + `sendspin-player` code path as the only player in the tree.
**Architecture:** `cmd/ma-player` and `cmd/test-sync` both call `internal/app.Player`, which wires together `internal/client`, `internal/audio`, `internal/player` (scheduler), `internal/artwork`, and `internal/sync` into a parallel player pipeline that predates `pkg/sendspin`. Since PR #26 migrated `internal/app.Player`'s output layer to `pkg/audio/output.Malgo`, `ma-player` is functionally equivalent to `sendspin-player` — both negotiate the same supported-formats list, both route 24-bit hi-res through malgo, both connect to Music Assistant cleanly. Empirically verified: `sendspin-player.exe` connects to Music Assistant and plays 192kHz/24-bit successfully (log evidence in `sendspin-player.log` from v1.2.0 verification). This plan removes the duplicate pipeline and its supporting packages, then deletes the `pkg/sync.SetGlobalClockSync`/`ServerMicrosNow` deprecation shims that only existed to keep the legacy readers working.
**Tech Stack:** Go 1.24+, existing `pkg/sendspin`, `pkg/sync`, `pkg/audio`, `pkg/protocol`, `pkg/discovery`.
**Context:**
- Resolves issue #28 (sync deprecation migration).
- Partially addresses issue #35 (release workflow only builds `ma-player` + `sendspin-player` Linux binaries — after this, only `sendspin-player` remains to ship).
- Does NOT fix issue #27 (unknown binary type 8), #29 (memory stats), #30 (clock offset display), #31 (duplicate Resampler), #32 (hello log spam), #33 (resonate-artwork cache dir), #34 (FLAC stub) — those are separate and unrelated to this cleanup.
- The `docs/superpowers/plans/2026-04-12-layered-architecture.md` plan is scoped to `pkg/sendspin` and does not overlap with this work.
**Out of scope:**
- Any refactor of `pkg/sendspin.Player` itself.
- Deletion of `internal/server/` — that package is still live and imported by `cmd/sendspin-server` and `pkg/sendspin/server.go`.
- Deletion of `internal/discovery/` — still imported by `internal/server`, `main.go`, and `pkg/sendspin`.
- Deletion of `internal/version/` — still imported by `main.go`.
- Deletion of `internal/ui/` — still imported by `main.go` for the TUI.
---
## Dependency Audit Summary
Reverse-import scan before writing this plan confirmed the following (all grep paths relative to repo root):
| Package | Non-legacy importers (survive) | Legacy importers (die with `internal/app`) |
|---|---|---|
| `internal/app` | (none) | `cmd/ma-player/main.go`, `cmd/test-sync/main.go` |
| `internal/audio` | (none) | `internal/app/player.go`, `internal/player/scheduler.go` |
| `internal/client` | (none) | `internal/app/player.go` |
| `internal/player` | (none) | `internal/app/player.go` |
| `internal/artwork` | (none) | `internal/app/player.go` |
| `internal/protocol` | (none — already orphaned) | (none) |
| `internal/sync` | `main.go` (Quality enum only), `internal/ui/model.go`, `internal/ui/model_test.go` | `internal/app/player.go`, `internal/player/scheduler.go` |
| `internal/ui` | `main.go` | `internal/app/player.go` |
| `internal/version` | `main.go` | `internal/app/player.go` |
| `internal/discovery` | `internal/server/server.go`, `pkg/sendspin/{client_dialer,client_dialer_test,client_discovery_integration_test,server}.go`, `main.go` | `internal/app/player.go` |
| `internal/server` | `cmd/sendspin-server/main.go`, `pkg/sendspin/server.go` | (none) |
**Key observation:** `pkg/sync.Quality` is byte-for-byte equivalent to `internal/sync.Quality` (same type, same three constants in the same iota order). `main.go`'s current `pkg/sync.Quality``internal/sync.Quality` conversion block (lines 207216) is pure ceremony. Migrating the three callers of `internal/sync.Quality` (`internal/ui/model.go`, `internal/ui/model_test.go`, `main.go`) to `pkg/sync.Quality` allows `internal/sync/` to be deleted after `internal/app` and `internal/player` go.
---
## File Structure
**Delete entirely:**
| Path | Lines | Notes |
|---|---|---|
| `cmd/ma-player/main.go` | ~90 | Duplicate of root `main.go` via legacy stack |
| `cmd/test-sync/main.go` | ~50 | Clock-sync debug harness, unused |
| `internal/app/player.go` | 567 | Legacy orchestrator |
| `internal/app/player_test.go` | 229 | |
| `internal/audio/decoder.go` | 125 | Duplicate of `pkg/audio/decode` |
| `internal/audio/decoder_test.go` | 64 | |
| `internal/audio/types.go` | 59 | |
| `internal/client/websocket.go` | 378 | Duplicate of `pkg/protocol.Client` |
| `internal/client/websocket_test.go` | 24 | |
| `internal/player/scheduler.go` | 238 | Duplicate of `pkg/sendspin.Scheduler` |
| `internal/player/scheduler_test.go` | 44 | |
| `internal/artwork/downloader.go` | 108 | Not used by `pkg/sendspin.Player` today |
| `internal/artwork/downloader_test.go` | 278 | |
| `internal/sync/clock.go` | 139 | Duplicate of `pkg/sync/clock.go` |
| `internal/sync/clock_test.go` | (varies) | |
| `internal/protocol/messages.go` | 130 | Already orphaned (no non-internal importers) |
| `internal/protocol/messages_test.go` | 79 | |
**Modify:**
| Path | Change |
|---|---|
| `internal/ui/model.go` | Replace `internal/sync` import with `pkg/sync`; type of `syncQuality` and `StatusMsg.SyncQuality` unchanged in name, changes in import source |
| `internal/ui/model_test.go` | Same replacement |
| `main.go` | Drop `internalsync "github.com/Sendspin/sendspin-go/internal/sync"` import; delete the Quality conversion block at ~lines 207216; pass `stats.SyncQuality` directly to `ui.StatusMsg` |
| `pkg/sync/clock.go` | Delete package-level deprecated `SetGlobalClockSync`, `ServerMicrosNow`, and the `globalClockSync` / `globalDeprecationWarned` package variables |
| `pkg/sendspin/player.go` | Delete the `sync.SetGlobalClockSync(recv.ClockSync())` compatibility-shim call (currently at line 157 per PR #26 final state) |
| `.github/workflows/release.yml` | Remove `ma-player` from build matrix if present |
| `Makefile` | Remove any `ma-player` or `test-sync` targets if present |
Total deletion: ~25002600 lines across ~17 files, plus small modifications to 5 surviving files.
---
## Pre-flight: Branch setup
- [ ] **Step 0a: Start from clean main**
```bash
git checkout main
git pull --ff-only
git worktree add -b feat/rip-legacy-cli ../sendspin-go-worktrees/rip-legacy-cli origin/main
cd ../sendspin-go-worktrees/rip-legacy-cli
```
- [ ] **Step 0b: Verify baseline green before any changes**
```bash
export PATH="/c/msys64/mingw64/bin:$PATH"
go build ./...
go test -count=1 ./... 2>&1 | tail -25
```
Expected: all packages pass. If anything is red before you start, stop and investigate.
- [ ] **Step 0c: Commit the plan doc on the feature branch**
```bash
# If the plan file is on main, pull it in. If the plan lives elsewhere, cp or cherry-pick.
# Assuming the plan file is already at docs/superpowers/plans/2026-04-14-rip-legacy-cli-stack.md on main:
git log --oneline main -- docs/superpowers/plans/2026-04-14-rip-legacy-cli-stack.md
# If the file exists, it's already on the branch via the fresh origin/main base.
# If not, commit it explicitly:
# git add docs/superpowers/plans/2026-04-14-rip-legacy-cli-stack.md
# git commit -m "docs: add rip-legacy-cli implementation plan"
```
---
### Task 1: Migrate `internal/ui` to `pkg/sync.Quality`
`internal/ui/model.go` and its test file both import `internal/sync` for the `Quality` enum. Switch them to `pkg/sync`, which has the same type and the same constants (`QualityGood`, `QualityDegraded`, `QualityLost`). After this task, `internal/ui` no longer imports `internal/sync`.
**Files:**
- Modify: `internal/ui/model.go`
- Modify: `internal/ui/model_test.go`
- [ ] **Step 1: Update the import in `internal/ui/model.go`**
Find the line:
```go
"github.com/Sendspin/sendspin-go/internal/sync"
```
Replace with:
```go
"github.com/Sendspin/sendspin-go/pkg/sync"
```
The package name used in the file is `sync` in both cases (no alias needed). All existing references like `sync.Quality`, `sync.QualityGood`, `sync.QualityDegraded`, `sync.QualityLost` continue to resolve correctly because `pkg/sync` has the same identifiers.
- [ ] **Step 2: Update the import in `internal/ui/model_test.go`**
Same replacement. If the test file uses `sync.Quality*` constants they all resolve against the new package identically.
- [ ] **Step 3: Verify `internal/ui` builds and tests pass**
```bash
go build ./internal/ui/
go test ./internal/ui/
```
Expected: clean build, all tests pass.
- [ ] **Step 4: Verify `main.go` still builds**
```bash
go build .
```
Expected: clean. `main.go` still converts `pkg/sync.Quality``internal/sync.Quality` in its own code at this point; that conversion keeps working because `internal/sync` is still alive.
- [ ] **Step 5: Commit**
```bash
git add internal/ui/model.go internal/ui/model_test.go
git commit -m "refactor(internal/ui): use pkg/sync.Quality instead of internal/sync.Quality
pkg/sync.Quality is byte-for-byte equivalent to internal/sync.Quality
(same type, same three constants in the same iota order). Switching
the TUI's only internal/sync consumer is step 1 of deleting the
legacy internal/ stack."
```
---
### Task 2: Drop the `internal/sync` conversion layer in `main.go`
`main.go` currently imports both `pkg/sync` (as `sync`) and `internal/sync` (as `internalsync`), and explicitly converts `pkg/sync.Quality` to `internal/sync.Quality` before constructing a `ui.StatusMsg`. After Task 1, `ui.StatusMsg.SyncQuality` is typed as `pkg/sync.Quality`, so the conversion is dead ceremony. Remove it and drop the `internal/sync` import.
**Files:**
- Modify: `main.go`
- [ ] **Step 1: Remove the `internalsync` import**
Find in the import block:
```go
internalsync "github.com/Sendspin/sendspin-go/internal/sync"
```
Delete that line.
- [ ] **Step 2: Remove the Quality conversion block**
Find in `statsUpdateLoop` (currently around lines 207216):
```go
// Convert pkg/sync.Quality to internal/sync.Quality
var syncQuality internalsync.Quality
switch stats.SyncQuality {
case 0: // QualityGood
syncQuality = internalsync.QualityGood
case 1: // QualityDegraded
syncQuality = internalsync.QualityDegraded
case 2: // QualityLost
syncQuality = internalsync.QualityLost
}
```
Delete the entire block.
Then find the `ui.StatusMsg` construction that uses the `syncQuality` local variable (a few lines below the deleted block):
```go
updateTUI(ui.StatusMsg{
Received: stats.Received,
Played: stats.Played,
Dropped: stats.Dropped,
BufferDepth: stats.BufferDepth,
SyncRTT: stats.SyncRTT,
SyncQuality: syncQuality,
Goroutines: runtime.NumGoroutine(),
MemAlloc: 0,
MemSys: 0,
})
```
Change the `SyncQuality: syncQuality,` line to:
```go
SyncQuality: stats.SyncQuality,
```
(Passing `pkg/sync.Quality` directly, now that `ui.StatusMsg.SyncQuality` is typed as `pkg/sync.Quality` after Task 1.)
- [ ] **Step 3: Verify the root binary builds**
```bash
go build .
```
Expected: clean build, produces `sendspin-player` (or `sendspin-player.exe` on Windows). If you see `undefined: internalsync`, a reference was missed — grep `internalsync` in `main.go` and remove any remaining hits.
- [ ] **Step 4: Verify full build and tests**
```bash
go build ./...
go test ./... 2>&1 | tail -25
```
Expected: all packages still pass. `internal/sync` is still present and still imported by `internal/app/player.go` and `internal/player/scheduler.go` — those die in a later task.
- [ ] **Step 5: Commit**
```bash
git add main.go
git commit -m "refactor(main): drop internal/sync conversion layer
ui.StatusMsg.SyncQuality is now pkg/sync.Quality (see prior commit),
so the pkg/sync -> internal/sync conversion block is pure ceremony.
Pass stats.SyncQuality through directly."
```
---
### Task 3: Delete `cmd/ma-player` and `cmd/test-sync`
Both CLIs import `internal/app`, which is the only non-legacy consumer of that package. Deleting them orphans `internal/app` for deletion in the next task.
**Files:**
- Delete: `cmd/ma-player/main.go`
- Delete: `cmd/test-sync/main.go`
- [ ] **Step 1: Confirm these are the only importers of `internal/app`**
```bash
grep -rln "\"github.com/Sendspin/sendspin-go/internal/app\"" --include='*.go' .
```
Expected output (exactly):
```
./cmd/ma-player/main.go
./cmd/test-sync/main.go
```
If there are other hits, STOP and report as BLOCKED — the scope was assumed wrong.
- [ ] **Step 2: Delete the CLI directories**
```bash
git rm -r cmd/ma-player cmd/test-sync
```
- [ ] **Step 3: Check the Makefile for references**
```bash
grep -n "ma-player\|test-sync" Makefile
```
If any targets or recipes name `ma-player` or `test-sync`, delete those lines. The existing `build`, `server`, `player`, `test`, `lint`, `clean`, `install` targets should be unaffected. Example: if you see
```makefile
ma-player:
go build -o ma-player ./cmd/ma-player
```
delete the target and any references to it in the phony list or the top-level `all:`/`build:` dependency lists.
- [ ] **Step 4: Check the release workflow for references**
```bash
grep -n "ma-player\|test-sync" .github/workflows/release.yml
```
If any build steps name these binaries, delete them. This partially addresses issue #35 (release workflow gap) by removing the entries that will no longer exist.
- [ ] **Step 5: Verify the full module still builds**
```bash
go build ./...
```
Expected: clean. Nothing compiles against `cmd/ma-player` or `cmd/test-sync` (they were standalone `package main` binaries), so the rest of the tree builds fine.
- [ ] **Step 6: Verify `internal/app` now has no importers**
```bash
grep -rln "\"github.com/Sendspin/sendspin-go/internal/app\"" --include='*.go' .
```
Expected output: empty (no hits).
- [ ] **Step 7: Commit**
```bash
git add -A cmd/ Makefile .github/workflows/release.yml
git commit -m "refactor: delete cmd/ma-player and cmd/test-sync
ma-player duplicated sendspin-player's functionality via the legacy
internal/app stack; verified empirically that sendspin-player.exe
handles the Music Assistant case cleanly, including 192kHz/24-bit
PCM hi-res streaming (v1.2.0 verification logs). test-sync was a
one-off clock-sync debug harness with no remaining users. Removing
both is step 1 of deleting the duplicate internal/ pipeline."
```
---
### Task 4: Delete `internal/app`
After Task 3, no caller remains. `internal/app/player.go` and its test file both die. This orphans `internal/audio`, `internal/client`, `internal/player`, `internal/artwork`, `internal/version`, and `internal/discovery` of their legacy consumer — though some of those packages (version, discovery) still have non-legacy consumers and survive.
**Files:**
- Delete: `internal/app/player.go`
- Delete: `internal/app/player_test.go`
- [ ] **Step 1: Confirm `internal/app` still has zero importers**
```bash
grep -rln "\"github.com/Sendspin/sendspin-go/internal/app\"" --include='*.go' .
```
Expected output: empty.
- [ ] **Step 2: Delete the package**
```bash
git rm -r internal/app
```
- [ ] **Step 3: Verify build and test**
```bash
go build ./...
go test -count=1 ./... 2>&1 | tail -25
```
Expected: all surviving packages build and pass. `internal/audio`, `internal/client`, `internal/player`, `internal/artwork` will still build independently — their tests continue to pass within their own packages because they don't depend on `internal/app`. The one surprise that could happen is if any package depends on `internal/app` in a way the grep missed (e.g., via test helpers or a build-tagged file). If the build fails, STOP and report.
- [ ] **Step 4: Commit**
```bash
git add -A internal/app/
git commit -m "refactor(internal/app): delete legacy player orchestrator
Only callers were cmd/ma-player and cmd/test-sync, both deleted in
the previous commit. This removes ~800 lines of duplicate pipeline
wiring that predated pkg/sendspin."
```
---
### Task 5: Delete `internal/player`
`internal/player/scheduler.go` was only used by `internal/app`. After Task 4, it has no importers. This is the last non-test consumer of `internal/audio` and (along with the earlier `internal/ui`/`main.go` migration) of `internal/sync`.
**Files:**
- Delete: `internal/player/scheduler.go`
- Delete: `internal/player/scheduler_test.go`
- [ ] **Step 1: Confirm `internal/player` has zero importers**
```bash
grep -rln "\"github.com/Sendspin/sendspin-go/internal/player\"" --include='*.go' .
```
Expected output: empty.
- [ ] **Step 2: Delete the package**
```bash
git rm -r internal/player
```
- [ ] **Step 3: Verify build and test**
```bash
go build ./...
go test -count=1 ./... 2>&1 | tail -25
```
Expected: all packages pass.
- [ ] **Step 4: Commit**
```bash
git add -A internal/player/
git commit -m "refactor(internal/player): delete legacy scheduler
The only caller, internal/app, was deleted in the previous commit.
pkg/sendspin has its own Scheduler that serves the modern player
pipeline."
```
---
### Task 6: Delete the remaining orphaned `internal/` packages
After Tasks 4 and 5, the following packages have no importers anywhere in the tree:
- `internal/audio/` (decoder.go, decoder_test.go, types.go)
- `internal/client/` (websocket.go, websocket_test.go)
- `internal/artwork/` (downloader.go, downloader_test.go)
- `internal/sync/` (clock.go, clock_test.go)
- `internal/protocol/` (messages.go, messages_test.go) — already orphaned before this PR; deleting it now finishes the sweep
**Files:**
- Delete: `internal/audio/` (directory)
- Delete: `internal/client/` (directory)
- Delete: `internal/artwork/` (directory)
- Delete: `internal/sync/` (directory)
- Delete: `internal/protocol/` (directory)
- [ ] **Step 1: Confirm every target package has zero importers**
```bash
for pkg in audio client artwork sync protocol; do
echo "=== internal/$pkg ==="
grep -rln "\"github.com/Sendspin/sendspin-go/internal/$pkg\"" --include='*.go' . || echo "(clean)"
done
```
Expected: all five should print `(clean)`. If any one still has importers, STOP and report — earlier tasks missed something.
- [ ] **Step 2: Delete the five packages**
```bash
git rm -r internal/audio internal/client internal/artwork internal/sync internal/protocol
```
- [ ] **Step 3: Verify build and test**
```bash
go build ./...
go test -count=1 ./... 2>&1 | tail -25
```
Expected: all surviving packages pass. The only remaining `internal/` packages should now be:
```bash
ls internal/
# expected: discovery server ui version
```
- [ ] **Step 4: Commit**
```bash
git add -A internal/
git commit -m "refactor: delete orphaned internal/ packages
After removing internal/app and internal/player, the following
packages have no remaining importers in the tree:
- internal/audio (duplicate of pkg/audio/decode)
- internal/client (duplicate of pkg/protocol.Client)
- internal/artwork (unused by pkg/sendspin.Player)
- internal/sync (duplicate of pkg/sync)
- internal/protocol (already orphaned pre-PR)
Surviving internal/ packages: discovery, server, ui, version."
```
---
### Task 7: Delete the `pkg/sync` deprecation shims
With `internal/player/scheduler.go` and `internal/app/player.go` deleted, nothing in the tree reads from the package-level `sync.ServerMicrosNow()` or writes to the global via `sync.SetGlobalClockSync()`. The compatibility shim at `pkg/sendspin/player.go` (line ~157 per PR #26 final state) also becomes dead — it was only there so the `internal/` readers could see the `ClockSync` via the global.
This resolves issue #28 (sync deprecation migration) in full.
**Files:**
- Modify: `pkg/sync/clock.go`
- Modify: `pkg/sendspin/player.go`
- [ ] **Step 1: Confirm no caller of `sync.ServerMicrosNow()` or `sync.SetGlobalClockSync`**
```bash
grep -rn "sync\.ServerMicrosNow\|sync\.SetGlobalClockSync" --include='*.go' . | grep -v "_test.go" | grep -v "pkg/sync/clock.go"
```
Expected: no hits in production code. Test files may still reference the deprecated symbols if the `pkg/sync` package's own tests exercise them — those will need updating in Step 3 if so.
- [ ] **Step 2: Delete the deprecated package-level functions from `pkg/sync/clock.go`**
In `pkg/sync/clock.go`, find and delete these three sections:
```go
// Deprecated: ServerMicrosNow returns current time in server's reference frame (us).
// Use ClockSync.ServerMicrosNow() on the instance from Receiver.ClockSync() instead.
func ServerMicrosNow() int64 {
cs := globalClockSync
// ... (entire function body)
}
```
```go
var (
globalClockSync *ClockSync
globalDeprecationWarned bool
)
// Deprecated: SetGlobalClockSync sets the global clock sync instance.
// Use Receiver.ClockSync() instead for new code.
func SetGlobalClockSync(cs *ClockSync) {
if !globalDeprecationWarned {
log.Printf("Warning: SetGlobalClockSync is deprecated, use Receiver.ClockSync() instead")
globalDeprecationWarned = true
}
globalClockSync = cs
}
```
Delete both declarations (`var (...)` block and both functions) entirely. Do NOT touch the instance method `(cs *ClockSync) ServerMicrosNow()` — that one stays; it's the replacement API.
After the delete, check whether `pkg/sync/clock.go` still imports `"log"`. If `log.Printf` is no longer called anywhere in the file, `go build` will flag the unused import — remove it.
- [ ] **Step 3: Update `pkg/sync/clock_test.go` if it references the deleted symbols**
```bash
grep -n "ServerMicrosNow\b\|SetGlobalClockSync\|globalClockSync" pkg/sync/clock_test.go
```
If the test file references the package-level `ServerMicrosNow()` (no receiver) or `SetGlobalClockSync`, those tests need to migrate to the instance method `cs.ServerMicrosNow()` or be deleted. In particular, if there's a `TestServerMicrosNow` (package-level) AND a `TestClockSync_ServerMicrosNow` (instance method), keep the latter and delete the former.
If `pkg/sync/clock_test.go` has no references to the deleted symbols, skip this step.
- [ ] **Step 4: Delete the compat shim from `pkg/sendspin/player.go`**
In `pkg/sendspin/player.go`, find the line (currently around line 157, inside `Connect()`):
```go
// Backward compat: set global clock sync
sync.SetGlobalClockSync(recv.ClockSync())
```
Delete both lines (the comment and the call).
After the delete, check whether `pkg/sendspin/player.go` still uses the `"github.com/Sendspin/sendspin-go/pkg/sync"` import for anything. If `sync.` no longer appears in the file, remove the import. If it appears for other types (e.g., `sync.Quality`), leave the import alone.
- [ ] **Step 5: Verify build and test**
```bash
go build ./...
go test -count=1 ./... 2>&1 | tail -25
```
Expected: all packages pass. Particularly `pkg/sync`, `pkg/sendspin`, and `main.go` all still build cleanly.
- [ ] **Step 6: Grep to confirm the deprecation warning string is gone**
```bash
grep -rn "SetGlobalClockSync is deprecated" --include='*.go' .
```
Expected: no hits. The deprecation warning no longer exists because the function that emits it no longer exists.
- [ ] **Step 7: Commit**
```bash
git add pkg/sync/clock.go pkg/sync/clock_test.go pkg/sendspin/player.go
git commit -m "refactor(pkg/sync): delete deprecated global clock-sync shims
The only callers of sync.ServerMicrosNow() (package-level) and
sync.SetGlobalClockSync() lived in the internal/ legacy stack,
which was deleted in earlier commits. The instance method
(*ClockSync).ServerMicrosNow() is now the only way to read
server-frame time.
Resolves #28."
```
---
### Task 8: Final sweep and `go mod tidy`
Dropping the `internal/` packages may have orphaned module dependencies that were only pulled in by the legacy stack. Run `go mod tidy` to clean `go.mod` and `go.sum`.
**Files:**
- Modify: `go.mod`, `go.sum` (if anything changed)
- [ ] **Step 1: Run `go mod tidy`**
```bash
go mod tidy
```
- [ ] **Step 2: Review the diff**
```bash
git diff go.mod go.sum
```
If `go.mod` / `go.sum` are unchanged, skip steps 3 and 4. If they are changed, read the diff — you should only see REMOVALS (a dependency that `internal/client` or `internal/audio` was the only user of). Any ADDITION is suspicious; investigate before committing.
Candidate dependencies that may disappear:
- Anything the legacy `internal/client/websocket.go` used that `pkg/protocol/client.go` doesn't
- Anything `internal/audio/decoder.go` used that `pkg/audio/decode/` doesn't
- [ ] **Step 3: Verify final build and full test suite**
```bash
go build ./...
go test -count=1 ./... 2>&1 | tail -25
```
Expected: clean + all packages pass.
- [ ] **Step 4: Commit (skip if `go mod tidy` was a no-op)**
```bash
git add go.mod go.sum
git commit -m "chore(deps): go mod tidy after legacy stack removal"
```
- [ ] **Step 5: Final structural check**
```bash
ls internal/
# expected: discovery server ui version
find cmd -type d
# expected: cmd cmd/sendspin-server
```
- [ ] **Step 6: Sanity: grep for any string references to deleted binaries**
```bash
grep -rn "ma-player\|test-sync" --include='*.md' --include='*.yml' --include='*.sh' --include='Makefile' .
```
Review every hit. Historical notes in `CHANGELOG.md`, `docs/PHASE1_IMPLEMENTATION.md`, and frozen plan docs under `docs/superpowers/plans/` may legitimately mention them — leave those alone. Current-state references (instructions, scripts, CI configs) need cleanup. Commit any doc updates as a separate trailing commit if needed:
```bash
git add <any updated docs>
git commit -m "docs: remove stale references to deleted ma-player/test-sync"
```
---
### Task 9: Open the PR
- [ ] **Step 1: Push the feature branch**
```bash
git push -u origin feat/rip-legacy-cli
```
- [ ] **Step 2: Open the PR**
```bash
gh pr create --title "refactor: delete legacy internal/ CLI stack (ma-player, test-sync, internal/app and deps)" --body "$(cat <<'EOF'
## Summary
Delete \`cmd/ma-player\`, \`cmd/test-sync\`, and the entire \`internal/\` legacy player pipeline they depended on:
- \`cmd/ma-player/\`, \`cmd/test-sync/\`
- \`internal/app/\`, \`internal/audio/\`, \`internal/client/\`, \`internal/player/\`, \`internal/artwork/\`, \`internal/sync/\`, \`internal/protocol/\`
Surviving \`internal/\` packages: \`discovery\`, \`server\`, \`ui\`, \`version\`.
Also removes the \`pkg/sync.SetGlobalClockSync\` / \`ServerMicrosNow\` package-level deprecation shims (and the compat-shim call in \`pkg/sendspin.Player.Connect\`), now that no readers remain. Resolves #28.
## Why
\`ma-player\` duplicated \`sendspin-player\`'s functionality via the legacy \`internal/\` stack. After PR #26 migrated \`internal/app.Player\`'s output layer to \`pkg/audio/output.Malgo\`, the only difference between the two binaries was that one went through \`pkg/sendspin.Receiver\` and the other went through \`internal/app.Player\` → \`internal/client\` → \`internal/player.Scheduler\`. Both negotiate the same hi-res-first format list, both route 24-bit PCM through malgo, both connect to Music Assistant. Empirically verified: \`sendspin-player.exe\` was tested end-to-end against a real Music Assistant server at 192kHz/24-bit during the v1.2.0 verification pass — log evidence in \`sendspin-player.log\`.
\`test-sync\` was a one-off clock-sync debugging CLI from the earlier "make server sync work" era; nobody runs it today. It's in git history if anyone needs it back.
## Scope
- **In:** delete the two CLIs and the seven orphaned \`internal/\` packages; migrate \`internal/ui\` and \`main.go\` off \`internal/sync.Quality\` onto \`pkg/sync.Quality\`; delete the \`pkg/sync\` deprecation shims.
- **Out:** \`internal/server\` (still used by \`cmd/sendspin-server\` and \`pkg/sendspin/server.go\`), \`internal/discovery\`, \`internal/ui\`, \`internal/version\` — all still have non-legacy consumers.
## Stats
~25002600 lines deleted across ~17 files. No new functionality, no behavioural change to \`sendspin-player\` or \`sendspin-server\`.
## Resolves
- #28 — deprecated \`sync.SetGlobalClockSync\` / \`sync.ServerMicrosNow\` shims removed along with their last readers.
- Partially addresses #35 — release workflow now only needs to ship \`sendspin-player\` and \`sendspin-server\` (ma-player gone).
## Test plan
- [x] \`go build ./...\` clean
- [x] \`go test -count=1 ./...\` green on every surviving package
- [ ] **Manual playback verification** — run \`./sendspin-player.exe --server <addr>\` against a real server and confirm audio plays, volume/mute work, TUI shows expected state. The expected behavior is identical to v1.2.0 since \`sendspin-player\` was untouched by this PR.
## Rollback
\`git revert\` the PR merge commit. All deletions are in one linear chain; a revert restores the full legacy stack with no partial state.
EOF
)"
```
---
## Verification Checklist (before merging the PR)
- [ ] `go build ./...` clean on a fresh checkout
- [ ] `go test -count=1 ./...` green
- [ ] `ls internal/` shows exactly `discovery server ui version`
- [ ] `ls cmd/` shows exactly `sendspin-server`
- [ ] `grep -rn "internal/app\|internal/client\|internal/player\|internal/audio\|internal/artwork\|internal/sync\|internal/protocol" --include='*.go' .` returns no hits
- [ ] `grep -rn "SetGlobalClockSync\|package-level ServerMicrosNow" --include='*.go' .` returns no hits
- [ ] `./sendspin-player` plays audio end-to-end against a real server (manual)
- [ ] `./sendspin-server` accepts a client and streams audio (manual)
## Rollback Plan
If anything turns out to depend on a deleted symbol and can't be trivially replaced:
1. `git revert` the PR merge commit. The full chain was designed as one revertable unit.
2. `go mod tidy` restores any dependencies that got pruned.
3. The legacy stack returns to life with no partial state.
The most likely sources of trouble, in order of probability:
1. An external consumer of the library that imports `internal/` (shouldn't happen — `internal/` packages are not importable from outside the module, by Go spec).
2. A build-tagged or generated file that references one of the deleted packages (extremely unlikely in this repo; grep confirmed no such files).
3. A script in `scripts/` or a CI workflow that invokes `ma-player` or `test-sync` binaries directly (Task 3 Step 4 sweeps for these).
None of these are expected to actually bite.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,276 @@
# sendspin-server: YAML config file + daemon mode
**Status:** Design approved 2026-04-20
**Tracks:** parity with `sendspin-player` config (closes equivalent of #40 for the server side)
## Problem
`sendspin-server` has no config-file support and no `--daemon` flag. To run it
under systemd today, an operator has to stuff every option into `ExecStart` or
an env file, and logging fights `journalctl` because the binary always opens
`sendspin-server.log`. The player solved this with a three-layer precedence
model (CLI > env > YAML > default), `WriteStringKey` for comment-preserving
round-trips, and a `--daemon` flag that logs to stdout only. The server should
get the same treatment.
## Goals
1. YAML config file at `/etc/sendspin/server.yaml` or `~/.config/sendspin/server.yaml` with the same search-order contract as `player.yaml`.
2. `SENDSPIN_SERVER_*` env var overlay with the same precedence rules as the player.
3. A `--daemon` flag that logs to stdout only (journalctl-friendly) and suppresses both the TUI and the log file.
4. `systemctl enable --now sendspin-server` works after `make install-daemon`.
5. Zero behavior change for users who don't opt in to any of the above.
## Non-goals
- **No write-back.** The server has no analog of the player's `client_id`; mDNS rediscovery handles instance identity at the network layer. `WriteStringKey` stays unused by the server (and stays generic in case future features need it).
- **No `-stream-logs` alias.** The player has it for historical reasons; the server starts clean without the redundant alias.
- **No unified `ConfigFile` struct.** Each binary keeps its own typed struct. Only the machinery (search paths, flag overlay) is shared.
## Approach
Refactor `pkg/sendspin/config.go` so the generic machinery takes a
`(envPrefix, map[string]string)` pair instead of a typed `*PlayerConfigFile`,
then add a parallel `ServerConfigFile` / `LoadServerConfig` /
`DefaultServerConfigPath` surface alongside the player's. The player's
`asStringMap()` already produces exactly the map the refactored function
needs, so the call-site change is one line.
No public API outside `pkg/sendspin` uses the old `ApplyEnvAndFile` signature
(verified by grep: only `main.go`, `config.go`, and `config_test.go`). The
technically-breaking signature change is contained.
## Architecture
### `pkg/sendspin/config.go` — refactor
Change:
```go
// Before
func ApplyEnvAndFile(fs *flag.FlagSet, setByUser map[string]bool, cfg *PlayerConfigFile) error
// After
func ApplyEnvAndFile(fs *flag.FlagSet, setByUser map[string]bool, envPrefix string, fileValues map[string]string) error
```
New unexported helpers:
- `loadYAMLConfig(searchPaths []string, out any) (string, error)` — opens the first existing file, unmarshals into `out`. Missing files are not an error (`(nil, "", nil)` equivalent).
- `userConfigPath(relative string) (string, error)` — wraps `os.UserConfigDir() + "/sendspin/" + relative`. Used by both `DefaultPlayerConfigPath` and `DefaultServerConfigPath`.
Existing public surface (`WriteStringKey`, `topLevelMapping`, `setOrAppendStringKey`, `atomicWriteFile`) is already generic and unchanged.
### `pkg/sendspin/config.go` — new server surface
```go
const ServerEnvPrefix = "SENDSPIN_SERVER_"
type ServerConfigFile struct {
Name string `yaml:"name,omitempty"`
Port *int `yaml:"port,omitempty"`
LogFile string `yaml:"log_file,omitempty"`
Debug *bool `yaml:"debug,omitempty"`
NoMDNS *bool `yaml:"no_mdns,omitempty"`
NoTUI *bool `yaml:"no_tui,omitempty"`
Audio string `yaml:"audio,omitempty"`
DiscoverClients *bool `yaml:"discover_clients,omitempty"`
Daemon *bool `yaml:"daemon,omitempty"`
}
func LoadServerConfig(explicitPath string) (*ServerConfigFile, string, error)
func DefaultServerConfigPath() (string, error)
func (c *ServerConfigFile) asStringMap() map[string]string
```
`LoadServerConfig` search order (first existing wins; missing is not an error):
1. `explicitPath` if non-empty
2. `$SENDSPIN_SERVER_CONFIG`
3. `<UserConfigDir>/sendspin/server.yaml`
4. `/etc/sendspin/server.yaml`
### Player call-site update
```go
// main.go (player)
if err := sendspin.ApplyEnvAndFile(
flag.CommandLine, setByUser, sendspin.PlayerEnvPrefix, cfg.asStringMap(),
); err != nil { ... }
```
`cfg.asStringMap()` on a nil `*PlayerConfigFile` must keep returning an empty map (existing behavior — confirmed in `TestApplyEnvAndFile_NilConfigStillHonorsEnv`). Preserve that contract.
### `cmd/sendspin-server/main.go` — new flags & wiring
Add:
```go
daemon = flag.Bool("daemon", false, "Daemon mode: log to stdout only (journalctl-friendly), no TUI, no log file")
configPath = flag.String("config", "", "Path to server.yaml config file. Default search: $SENDSPIN_SERVER_CONFIG, ~/.config/sendspin/server.yaml, /etc/sendspin/server.yaml.")
```
After `flag.Parse()`:
```go
setByUser := map[string]bool{"config": true}
flag.Visit(func(f *flag.Flag) { setByUser[f.Name] = true })
cfg, _, err := sendspin.LoadServerConfig(*configPath)
if err != nil { log.Fatalf("config: %v", err) }
if err := sendspin.ApplyEnvAndFile(
flag.CommandLine, setByUser, sendspin.ServerEnvPrefix, cfg.asStringMap(),
); err != nil { log.Fatalf("config overlay: %v", err) }
```
### Daemon mode
- `useTUI := !(*noTUI || *daemon)`
- Logging branch:
- `-daemon``log.SetOutput(os.Stdout)`; skip opening `*logFile`.
- TUI on → log to file only (unchanged).
- Neither → `io.MultiWriter(os.Stdout, f)` (unchanged).
- Non-TUI startup banner logs `"Starting Sendspin Server: %s (port %d)"` and, when `-daemon`, adds `"Daemon mode: logging to stdout only"`.
### Distribution artifacts
**`dist/systemd/sendspin-server.service`** — shape mirrors `sendspin-player.service`:
```ini
[Unit]
Description=Sendspin Server
Documentation=https://github.com/Sendspin/sendspin-go
After=network-online.target sound.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/sendspin-server --daemon
Restart=on-failure
RestartSec=5
EnvironmentFile=-/etc/default/sendspin-server
ExecStart=
ExecStart=/usr/local/bin/sendspin-server --daemon $SENDSPIN_SERVER_OPTS
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=read-only
PrivateTmp=true
[Install]
WantedBy=multi-user.target
```
`ProtectHome=read-only` (not `yes`) so `-audio /home/user/Music/...` still works.
**`dist/systemd/sendspin-server.env`** — operator-editable env file installed to `/etc/default/sendspin-server`:
```sh
# sendspin-server: extra CLI flags appended to ExecStart
# Prefer /etc/sendspin/server.yaml for structured config; use this for
# ad-hoc overrides or keys that aren't (yet) in the YAML.
# SENDSPIN_SERVER_OPTS="--debug --discover-clients"
SENDSPIN_SERVER_OPTS=""
```
**`dist/config/server.example.yaml`** — annotated starter, every key commented out so the file is a no-op until edited:
```yaml
# sendspin-server configuration
#
# Search order (first existing file wins; missing is not an error):
# 1. --config <path>
# 2. $SENDSPIN_SERVER_CONFIG
# 3. ~/.config/sendspin/server.yaml (user install)
# 4. /etc/sendspin/server.yaml (daemon / system-wide)
#
# Per-value precedence for every key below:
# CLI flag > SENDSPIN_SERVER_<UPPER_SNAKE> env > this file > built-in default
# --- Identity ---
# name: "Living Room Server"
# --- Network ---
# port: 8927
# no_mdns: false
# discover_clients: false
# --- Audio source ---
# Local file path, HTTP URL, or HLS URL. Empty = built-in test tone.
# audio: "/srv/music/radio.m3u8"
# --- Logging / runtime ---
# daemon: false
# no_tui: false
# log_file: "sendspin-server.log"
# debug: false
```
### Makefile
Split `install-daemon` / `uninstall-daemon` into leaf + aggregate targets:
- `install-player-daemon` — existing player install logic, renamed.
- `install-server-daemon` — new; installs binary to `/usr/local/bin`, unit file to `/etc/systemd/system`, env file to `/etc/default/sendspin-server`, example YAML to `/etc/sendspin/server.yaml`. Each of the two editable files is installed only if absent (guard pattern from the player).
- `install-daemon` — aggregate: `install-player-daemon install-server-daemon`.
- `uninstall-server-daemon` — stops/disables unit, removes binary and unit file, leaves `/etc/default/sendspin-server` and `/etc/sendspin/server.yaml` intact.
- `uninstall-daemon` — aggregate.
`clean` already removes `sendspin-server`; no change.
## Flag → YAML key mapping
| Flag | YAML key | Type | Default |
|---|---|---|---|
| `-port` | `port` | `*int` | 8927 |
| `-name` | `name` | `string` | `<hostname>-sendspin-server` |
| `-log-file` | `log_file` | `string` | `sendspin-server.log` |
| `-debug` | `debug` | `*bool` | false |
| `-no-mdns` | `no_mdns` | `*bool` | false |
| `-no-tui` | `no_tui` | `*bool` | false |
| `-audio` | `audio` | `string` | *(empty → test tone)* |
| `-discover-clients` | `discover_clients` | `*bool` | false |
| `-daemon` | `daemon` | `*bool` | false |
| `-config` | *(not in YAML — circular)* | `string` | *(empty)* |
## Precedence
For every key: **CLI flag > `SENDSPIN_SERVER_<UPPER_SNAKE>` env > `server.yaml` > built-in default**.
Matches the player exactly. Enforced by the shared `ApplyEnvAndFile` helper.
## Error handling
- Invalid YAML → `log.Fatalf("config: %v", err)` at startup.
- Invalid env var (e.g., `SENDSPIN_SERVER_PORT=abc`) → fatal; error message includes the offending flag name. Behavior inherited from the shared helper.
- Missing config file → silent no-op; all keys fall through to flag defaults. This is the documented contract.
- `-audio` validation stays in `server.NewAudioSource`; config loading only carries the string.
- `-daemon` combined with TUI flags → daemon wins, no warning. Matches player behavior.
## Testing
**Existing player tests** (`pkg/sendspin/config_test.go`) — each `ApplyEnvAndFile(..., cfg)` call-site must be rewritten to `ApplyEnvAndFile(..., PlayerEnvPrefix, cfg.asStringMap())` to match the new signature. `TestApplyEnvAndFile_NilConfigStillHonorsEnv` passes a nil map instead of a nil struct (a nil `map[string]string` iterates as empty, so the env-only path behaves identically). No assertion changes; the precedence tests then exercise the shared code path for both binaries.
**New server tests** — structural coverage only:
- `TestLoadServerConfig_ExplicitPathWithAllKeys` — round-trip every `ServerConfigFile` field through YAML.
- `TestLoadServerConfig_MissingFileIsNotAnError` — contract symmetry.
- `TestLoadServerConfig_EnvPathHonored``SENDSPIN_SERVER_CONFIG` picked up.
- `TestApplyEnvAndFile_ServerEnvPrefix` — confirms the generalized `envPrefix` parameter routes `SENDSPIN_SERVER_*` correctly.
No new `WriteStringKey` tests (server doesn't use write-back).
## Manual verification (part of the plan's completion step)
1. `sendspin-server` with no config file → unchanged behavior.
2. `sendspin-server --config /tmp/s.yaml` with `port: 9000` → binds 9000.
3. `SENDSPIN_SERVER_PORT=9001 sendspin-server --config /tmp/s.yaml` → binds 9001 (env beats file).
4. `sendspin-server --port 9002 --config /tmp/s.yaml` → binds 9002 (CLI beats env + file).
5. `sendspin-server --daemon` → no TUI, stdout logging with timestamps, no `sendspin-server.log` created.
6. Linux box: `make install-daemon && systemctl enable --now sendspin-server && journalctl -u sendspin-server -f` → clean startup.
## Out-of-scope (future work)
- `server_id` / persistent identity for the server.
- A unified `pkg/sendspin/configfile` sub-package if a third binary ever joins (YAGNI until then).
- README deep-dive on daemon operation — this work adds a one-line pointer only.

View File

@@ -0,0 +1,97 @@
# sendspin-player: Raspberry Pi quickstart script
**Status:** Design approved 2026-05-01
**Tracks:** lower the on-ramp for Pi-as-player setups using prebuilt arm64 release tarballs
## Problem
Setting up a Raspberry Pi as a Sendspin player today requires the user to: install build-time deps via `install-deps.sh`, clone the repo, build with the CGo toolchain, then either run the binary by hand or wire up systemd through `make install-player-daemon`. None of that is friendly for a "stick a Pi behind the speakers and forget about it" workflow, and almost all of it is unnecessary now that the GitHub Releases pipeline ships ready-to-run `linux-arm64` tarballs.
A `curl | sudo bash` quickstart that takes a fresh 64-bit Raspberry Pi OS install to a running, mDNS-discoverable player in one command closes that gap.
## Goals
1. Single-command install: `curl -fsSL https://raw.githubusercontent.com/Sendspin/sendspin-go/main/scripts/quickstart-pi.sh | sudo bash` brings up the player on a fresh 64-bit Raspberry Pi OS — Lite is the recommended target (headless, no PulseAudio/PipeWire, lower scheduling jitter), full desktop also works. Bookworm or newer is required for the `libflac12` runtime package.
2. Idempotent re-run: invoking the script a second time upgrades the binary and refreshes the unit file in place.
3. Optional one-shot configuration via flags: `bash -s -- --name living-room --device "USB DAC"`.
4. Clean uninstall via `--uninstall`.
5. Pin a specific version via `--version v1.6.2` (default: latest).
6. Zero changes to existing build, daemon-install, or release flows.
## Non-goals
- **No 32-bit Pi support.** Releases ship `linux-arm64` only. The script detects `armv6l` / `armv7l` and exits with a clear pointer to 64-bit Raspberry Pi OS. Pi 1 / Zero (v1) / Zero W are excluded by hardware; Pi 2 is excluded by OS choice.
- **No non-Debian distros.** The script's package-install step targets `apt-get`. Other distros must follow the README's manual install steps.
- **No Debian < 12 (Bullseye and earlier).** The script hard-codes `libflac12`, which is Bookworm's package name. On older Debian / Pi OS, `apt-get install libflac12` fails loudly with "no installation candidate" — that error is acceptable as the user-facing rejection rather than adding fallback logic.
- **No checksum / signature verification.** HTTPS to `github.com` is the same trust boundary the `curl | bash` itself crosses; layering a second integrity check adds operational cost without materially changing the threat model. May reconsider if signed releases land later.
- **No interactive device picker.** `--device "<exact name>"` is the documented path. Users wanting a list run `sendspin-player --list-audio-devices` after install.
- **No support for running `sendspin-server` via this script.** Pi-as-server is a less common configuration; if it becomes one, a sibling script is the right move, not flag bloat in this one.
- **No purge of `/etc/sendspin/` on `--uninstall`.** Matches the existing `make uninstall-player-daemon` behavior; user state is precious.
## Approach
A single shell script at `scripts/quickstart-pi.sh`, fetched via raw GitHub URL and piped to `sudo bash`. Self-contained: every helper is inlined, no second fetch, no source tree dependency.
The script reuses every artifact already shipped in the repo:
- The `dist/systemd/sendspin-player.service` unit (already root-running with `ProtectSystem=strict` / `ProtectHome=read-only` hardening; no change).
- The `dist/systemd/sendspin-player.env` file (already wires `SENDSPIN_PLAYER_OPTS` into `ExecStart`).
- The `dist/config/player.example.yaml` file (already configured with sensible defaults; the daemon writes its own `client_id` back on first launch).
These are downloaded directly from `https://raw.githubusercontent.com/Sendspin/sendspin-go/<ref>/dist/...` at the resolved release tag (so a `--version v1.6.2` install gets the unit/config/env files from that tag, not from `main`).
The release tarball comes from GitHub's `/releases/latest/download/` redirect by default, or `/releases/download/<tag>/` when `--version` is set. No GitHub API call, no JSON parsing.
`/etc/default/sendspin-player` is touched only on first install OR when `--name` / `--device` are passed on this invocation. That makes "re-run with new flags" the documented reconfigure path; "re-run with no flags" is purely an upgrade.
## Flow
The script executes the following stages in order. Any non-zero exit aborts the run; idempotency is the recovery story.
1. **Argument parse.** `--name <s>`, `--device <s>`, `--version <tag>`, `--uninstall`, `-h|--help`. Unknown flags abort with usage.
2. **Pre-flight.**
- Effective UID must be 0 (else: print sudo hint, exit).
- `uname -m` must be `aarch64` (else: print 64-bit-OS hint, exit).
- `/etc/debian_version` must exist (else: point at README manual steps, exit).
- `command -v systemctl` must succeed.
3. **Uninstall short-circuit.** If `--uninstall` is set:
- `systemctl disable --now sendspin-player` (tolerate "no such unit" cleanly).
- Remove `/usr/local/bin/sendspin-player` and `/etc/systemd/system/sendspin-player.service`.
- `systemctl daemon-reload`.
- Print note: "Config preserved at `/etc/sendspin/` and `/etc/default/sendspin-player`. Remove manually for a full purge."
- Exit 0.
4. **Install runtime deps.** `apt-get update && apt-get install -y libopus0 libopusfile0 libflac12 libasound2 ca-certificates curl tar`. `libasound2` is the ALSA userspace library miniaudio links against; it is usually present on Pi OS Lite but not guaranteed on a truly minimal image, so we install it explicitly.
5. **Resolve version.** Default URL: `https://github.com/Sendspin/sendspin-go/releases/latest/download/sendspin-player-linux-arm64.tar.gz`. With `--version`: `https://github.com/Sendspin/sendspin-go/releases/download/<tag>/sendspin-player-linux-arm64.tar.gz`. Same logic produces the `dist/...` raw URL ref: `main` for default, `<tag>` for pinned.
6. **Stop service if running.** `systemctl is-active --quiet sendspin-player && systemctl stop sendspin-player`.
7. **Download + extract.** `curl -fSL <url> -o $TMP/sp.tar.gz`, `tar -xzf $TMP/sp.tar.gz -C $TMP`, `install -m 755 $TMP/sendspin-player /usr/local/bin/sendspin-player`. `$TMP` is `mktemp -d` and removed via `EXIT` trap.
8. **Install systemd unit.** `curl -fSL <raw-dist-url>/systemd/sendspin-player.service -o /etc/systemd/system/sendspin-player.service`. Always overwritten.
9. **Install env file (conditional).**
- If `/etc/default/sendspin-player` does not exist, fetch `dist/systemd/sendspin-player.env` from raw GitHub.
- If `--name` or `--device` were passed, write `SENDSPIN_PLAYER_OPTS="--name <n> --audio-device <d>"` to `/etc/default/sendspin-player` (overwriting). Each flag value is wrapped in single quotes with embedded single quotes escaped via the standard `'\''` pattern, so names like `Bob's Living Room` survive intact. Only the flags actually passed are emitted.
- Otherwise (file exists, no flags), leave it alone.
10. **Install YAML config (conditional).** If `/etc/sendspin/player.yaml` does not exist, `mkdir -p /etc/sendspin && curl -fSL <raw-dist-url>/config/player.example.yaml -o /etc/sendspin/player.yaml`. Otherwise leave it alone (preserves any `client_id` the daemon has already written back).
11. **Reload + enable + start.** `systemctl daemon-reload && systemctl enable --now sendspin-player`.
12. **Health check.** Sleep 2s; check `systemctl is-active sendspin-player`. If not active, print last 20 lines of `journalctl -u sendspin-player --no-pager` and exit non-zero.
13. **Success message.** Print resolved version, config file path, env file path, and `journalctl -u sendspin-player -f` for live logs.
## Error handling
- `set -euo pipefail` at the top of the script.
- Every download uses `curl -fSL` so a 404 (e.g. typo'd `--version`) exits cleanly with the curl error.
- `EXIT` trap removes the staging tempdir and, on non-zero status, prints a one-line "If install failed mid-way, re-run the script — it's idempotent" hint.
- The binary is `install -m 755`'d only after the tarball extracts cleanly. A failed download leaves `/usr/local/bin/sendspin-player` untouched.
- Pre-flight failures print the *specific* missing requirement, never a generic "system not supported".
## Testing
- **Local smoke**: run in `arm64v8/debian:bookworm` (without systemd — verify deps install, binary lands, files in place; `systemctl` steps will fail loudly which is expected without an init system).
- **Real Pi**: run the actual `curl | bash` on a fresh 64-bit Raspberry Pi OS install, confirm the player appears in mDNS and Music Assistant.
- **Idempotency**: run twice on the Pi, confirm second invocation completes cleanly and the service stays up.
- **Reconfigure**: run with `--name` after a vanilla install, confirm `/etc/default/sendspin-player` is rewritten and the service picks up the new name.
- **Arch rejection**: run on `armv7l` (real Pi 2 or `qemu-user-static`), confirm clean error message naming the supported set.
- **Uninstall**: `bash quickstart-pi.sh --uninstall`, confirm service stopped/disabled, binary and unit removed, config preserved.
- **CI**: add `shellcheck scripts/quickstart-pi.sh` to the existing GitHub Actions lint workflow. No new workflow.
## Open questions
None at design time. Flag any post-implementation surprises in the PR.

View File

@@ -0,0 +1,179 @@
# Sendspin Examples
This directory contains example programs demonstrating how to use the Sendspin library.
## Available Examples
### [basic-player](basic-player/)
A simple audio player that connects to a Sendspin server and plays synchronized audio.
**Key features:**
- Simple configuration with callbacks
- Automatic clock synchronization
- Status monitoring and statistics
- Metadata display
**Run it:**
```bash
cd basic-player
go build
./basic-player -server localhost:8927
```
### [basic-server](basic-server/)
A simple streaming server that broadcasts a test tone to connected players.
**Key features:**
- Test tone generation (440 Hz)
- Multi-client support
- Automatic codec negotiation
- mDNS service advertisement
**Run it:**
```bash
cd basic-server
go build
./basic-server
```
### [custom-source](custom-source/)
Demonstrates how to implement custom `AudioSource` interfaces for generating or processing audio.
**Key features:**
- Multiple tone mixing (chords)
- Frequency sweeps (chirps)
- Custom audio generation
- AudioSource interface implementation
**Run it:**
```bash
cd custom-source
go build
./custom-source -mode chord
```
## Quick Start
### 1. Start a server
```bash
cd examples/basic-server
go build && ./basic-server
```
### 2. Connect a player
In another terminal:
```bash
cd examples/basic-player
go build && ./basic-player
```
You should hear a 440 Hz test tone playing with synchronized timing.
## Building All Examples
From the repository root:
```bash
go build ./examples/basic-player/
go build ./examples/basic-server/
go build ./examples/custom-source/
```
## Example Progression
1. **Start with basic-server** - Understand server setup and test tone streaming
2. **Try basic-player** - Learn player configuration and playback
3. **Explore custom-source** - Implement custom audio sources
## Key Concepts Demonstrated
### Player API (`pkg/sendspin`)
```go
// Create and configure player
config := sendspin.PlayerConfig{
ServerAddr: "localhost:8927",
PlayerName: "Living Room",
Volume: 80,
OnMetadata: func(meta sendspin.Metadata) {
fmt.Printf("Now playing: %s - %s\n", meta.Artist, meta.Title)
},
}
player, _ := sendspin.NewPlayer(config)
player.Connect()
player.Play()
```
### Server API (`pkg/sendspin`)
```go
// Create audio source
source := sendspin.NewTestTone(192000, 2)
// Create and start server
config := sendspin.ServerConfig{
Port: 8927,
Source: source,
}
server, _ := sendspin.NewServer(config)
server.Start()
```
### AudioSource Interface
```go
type AudioSource interface {
Read(samples []int32) (int, error)
SampleRate() int
Channels() int
Metadata() (title, artist, album string)
Close() error
}
```
## Audio Format
All examples use hi-res audio:
- **Sample rate**: 192 kHz (configurable)
- **Bit depth**: 24-bit
- **Channels**: 2 (stereo)
- **Format**: int32 PCM samples
## Network Discovery
Servers advertise via mDNS by default. Players can discover local servers automatically.
## Next Steps
- Check out the full CLI tools: `cmd/sendspin-server/` (and root main.go for player)
- Read the API documentation: `pkg/sendspin/`
- Explore lower-level APIs: `pkg/audio/`, `pkg/protocol/`, `pkg/sync/`
## Troubleshooting
**No audio playing?**
- Check server is running: `netstat -an | grep 8927`
- Check firewall allows port 8927
- Verify audio output device is working
**Connection refused?**
- Ensure server is started before connecting player
- Check server address is correct
- Try explicit IP instead of localhost
**Audio glitches or dropouts?**
- Increase buffer size: `-buffer 1000` (player)
- Check network latency
- Monitor clock sync quality in stats
## License
See the repository's LICENSE file for details.

View File

@@ -0,0 +1,74 @@
# Basic Player Example
This example demonstrates how to create a simple Sendspin player that connects to a server and plays audio.
## What it does
- Connects to a Sendspin server
- Starts audio playback with automatic clock synchronization
- Displays metadata (title, artist, album) when received
- Shows playback status and statistics
- Supports volume control and mute
## Building
```bash
cd examples/basic-player
go build
```
## Running
Connect to a local server:
```bash
./basic-player
```
Connect to a specific server:
```bash
./basic-player -server 192.168.1.100:8927
```
Set custom player name and volume:
```bash
./basic-player -name "Living Room" -volume 80
```
## Command-line options
- `-server` - Server address (default: localhost:8927)
- `-name` - Player name (default: "Basic Player")
- `-volume` - Initial volume 0-100 (default: 80)
## Key features demonstrated
1. **Simple configuration** - Just specify server address and player name
2. **Callbacks** - Handle metadata, state changes, and errors
3. **Automatic sync** - Clock synchronization happens automatically
4. **Status monitoring** - Get real-time playback statistics
## Code highlights
```go
// Create player with configuration
config := sendspin.PlayerConfig{
ServerAddr: "localhost:8927",
PlayerName: "Living Room",
Volume: 80,
OnMetadata: func(meta sendspin.Metadata) {
log.Printf("Now playing: %s - %s", meta.Artist, meta.Title)
},
}
player, _ := sendspin.NewPlayer(config)
player.Connect()
player.Play()
```
## Next steps
- See `examples/custom-source/` for custom audio source implementation
- Check out the player CLI (root main.go) for a full-featured TUI player

View File

@@ -0,0 +1,88 @@
// ABOUTME: Basic Sendspin player example
// ABOUTME: Demonstrates how to connect to a server and play audio
package main
import (
"flag"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"github.com/Sendspin/sendspin-go/pkg/sendspin"
)
func main() {
serverAddr := flag.String("server", "localhost:8927", "Sendspin server address")
playerName := flag.String("name", "Basic Player", "Player name")
volume := flag.Int("volume", 80, "Initial volume (0-100)")
flag.Parse()
config := sendspin.PlayerConfig{
ServerAddr: *serverAddr,
PlayerName: *playerName,
Volume: *volume,
BufferMs: 500, // 500ms playback buffer
DeviceInfo: sendspin.DeviceInfo{
ProductName: "Sendspin Example Player",
Manufacturer: "Sendspin",
SoftwareVersion: "1.0.0",
},
OnMetadata: func(meta sendspin.Metadata) {
log.Printf("Now playing: %s - %s (%s)", meta.Artist, meta.Title, meta.Album)
},
OnStateChange: func(state sendspin.PlayerState) {
log.Printf("State changed: %s (volume: %d, muted: %v)", state.State, state.Volume, state.Muted)
},
OnError: func(err error) {
log.Printf("Error: %v", err)
},
}
player, err := sendspin.NewPlayer(config)
if err != nil {
log.Fatalf("Failed to create player: %v", err)
}
defer player.Close()
log.Printf("Connecting to %s...", *serverAddr)
if err := player.Connect(); err != nil {
log.Fatalf("Failed to connect: %v", err)
}
log.Printf("Connected! Starting playback...")
if err := player.Play(); err != nil {
log.Fatalf("Failed to start playback: %v", err)
}
go func() {
for {
status := player.Status()
stats := player.Stats()
if status.Connected {
log.Printf("Status: %s | %s %dHz %dch %dbit | Buffer: %dms | RTT: %dμs",
status.State,
status.Codec,
status.SampleRate,
status.Channels,
status.BitDepth,
stats.BufferDepth,
stats.SyncRTT)
}
// Sleep for 5 seconds
select {
case <-make(chan struct{}):
}
}
}()
// Wait for interrupt signal
fmt.Println("\nPress Ctrl+C to stop playback")
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
<-sigChan
log.Printf("Shutting down...")
}

View File

@@ -0,0 +1,101 @@
# Basic Server Example
This example demonstrates how to create a simple Sendspin streaming server that broadcasts a test tone to connected players.
## What it does
- Creates a Sendspin server that streams a 440Hz test tone
- Listens for WebSocket connections from players
- Broadcasts synchronized audio to all connected clients
- Supports mDNS service advertisement for easy discovery
- Handles multiple clients with different codec preferences
## Building
```bash
cd examples/basic-server
go build
```
## Running
Start server with defaults (192kHz/24-bit stereo):
```bash
./basic-server
```
Start on a different port:
```bash
./basic-server -port 9000
```
Configure sample rate and channels:
```bash
./basic-server -rate 48000 -channels 2
```
Disable mDNS:
```bash
./basic-server -mdns=false
```
## Command-line options
- `-port` - Server port (default: 8927)
- `-name` - Server name (default: "Basic Server")
- `-rate` - Sample rate in Hz (default: 192000)
- `-channels` - Number of channels (default: 2)
- `-mdns` - Enable mDNS advertisement (default: true)
## Key features demonstrated
1. **Simple setup** - Just create a source and start the server
2. **Test tone generation** - Built-in test tone for easy testing
3. **Automatic client handling** - Clients are managed automatically
4. **Multi-codec support** - Automatically negotiates best codec per client
5. **mDNS discovery** - Clients can find the server automatically
## Code highlights
```go
// Create audio source (440Hz test tone)
source := sendspin.NewTestTone(192000, 2)
// Create and start server
config := sendspin.ServerConfig{
Port: 8927,
Name: "My Server",
Source: source,
EnableMDNS: true,
}
server, _ := sendspin.NewServer(config)
server.Start()
```
## Testing
Start the server:
```bash
./basic-server
```
In another terminal, connect a player:
```bash
cd ../..
go run . -server localhost:8927
```
You should hear a 440Hz tone (A4 note) playing.
## Next steps
- See `examples/custom-source/` for implementing custom audio sources (files, streams, etc.)
- Check out the server CLI (`cmd/sendspin-server/`) for a full-featured server with TUI
- Modify the test tone frequency or add multiple frequencies for testing

View File

@@ -0,0 +1,77 @@
// ABOUTME: Basic Sendspin server example
// ABOUTME: Demonstrates how to create a simple streaming server
package main
import (
"flag"
"log"
"os"
"os/signal"
"syscall"
"github.com/Sendspin/sendspin-go/pkg/sendspin"
)
func main() {
port := flag.Int("port", 8927, "Server port")
serverName := flag.String("name", "Basic Server", "Server name")
sampleRate := flag.Int("rate", 192000, "Sample rate (Hz)")
channels := flag.Int("channels", 2, "Number of channels")
enableMDNS := flag.Bool("mdns", true, "Enable mDNS service advertisement")
flag.Parse()
log.Printf("Creating test tone source: %dHz, %d channels", *sampleRate, *channels)
source := sendspin.NewTestTone(*sampleRate, *channels)
config := sendspin.ServerConfig{
Port: *port,
Name: *serverName,
Source: source,
EnableMDNS: *enableMDNS,
Debug: false,
}
server, err := sendspin.NewServer(config)
if err != nil {
log.Fatalf("Failed to create server: %v", err)
}
log.Printf("Starting Sendspin server...")
log.Printf(" Name: %s", *serverName)
log.Printf(" Port: %d", *port)
log.Printf(" Audio: %dHz, %d channels, 24-bit", *sampleRate, *channels)
if *enableMDNS {
log.Printf(" mDNS: enabled")
}
errChan := make(chan error, 1)
go func() {
if err := server.Start(); err != nil {
errChan <- err
}
}()
// Print client info periodically
go func() {
for {
select {
case <-make(chan struct{}):
}
}
}()
log.Printf("\nServer running. Press Ctrl+C to stop")
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
select {
case <-sigChan:
log.Printf("Received interrupt signal, shutting down...")
case err := <-errChan:
log.Printf("Server error: %v", err)
}
server.Stop()
log.Printf("Server stopped")
}

View File

@@ -0,0 +1,158 @@
# Custom Audio Source Example
This example demonstrates how to implement the `AudioSource` interface to create custom audio generators for streaming.
## What it does
This example shows three different custom audio source implementations:
1. **MultiToneSource** - Mixes multiple sine waves (creates chords)
2. **SweepSource** - Generates frequency sweeps (chirps)
3. **Built-in TestTone** - Single frequency tone (for comparison)
Each demonstrates different aspects of the `AudioSource` interface.
## Building
```bash
cd examples/custom-source
go build
```
## Running
Generate an A major chord (440, 554, 659 Hz):
```bash
./custom-source -mode chord
```
Generate a frequency sweep from 220 to 880 Hz:
```bash
./custom-source -mode sweep
```
Generate a single 440 Hz tone:
```bash
./custom-source -mode single
```
## Command-line options
- `-port` - Server port (default: 8927)
- `-mode` - Source mode: chord, sweep, single (default: chord)
- `-rate` - Sample rate in Hz (default: 192000)
- `-channels` - Number of channels (default: 2)
## AudioSource Interface
To create a custom audio source, implement this interface:
```go
type AudioSource interface {
// Read fills the buffer with PCM samples (int32 for 24-bit audio)
Read(samples []int32) (int, error)
// SampleRate returns the sample rate
SampleRate() int
// Channels returns the number of channels
Channels() int
// Metadata returns title, artist, album
Metadata() (title, artist, album string)
// Close closes the audio source
Close() error
}
```
## Implementation details
### MultiToneSource
Generates multiple sine waves and mixes them together:
```go
type MultiToneSource struct {
frequencies []float64
sampleRate int
channels int
sampleIndex uint64
mu sync.Mutex
}
func (s *MultiToneSource) Read(samples []int32) (int, error) {
// For each sample frame:
for i := 0; i < numFrames; i++ {
t := float64(s.sampleIndex + i) / float64(s.sampleRate)
// Mix all frequencies
var mixed float64
for _, freq := range s.frequencies {
mixed += math.Sin(2 * math.Pi * freq * t)
}
mixed /= float64(len(s.frequencies))
// Convert to 24-bit PCM (int32)
pcmValue := int32(mixed * 8388607 * 0.5)
// Write to all channels
for ch := 0; ch < s.channels; ch++ {
samples[i*s.channels + ch] = pcmValue
}
}
return len(samples), nil
}
```
### SweepSource
Generates a time-varying frequency sweep:
```go
func (s *SweepSource) Read(samples []int32) (int, error) {
for i := 0; i < numFrames; i++ {
t := float64(s.sampleIndex + i) / float64(s.sampleRate)
// Calculate current frequency based on progress
progress := math.Mod(t, s.duration) / s.duration
freq := s.startFreq + (s.endFreq - s.startFreq) * progress
// Generate sine wave at current frequency
sample := math.Sin(2 * math.Pi * freq * t)
pcmValue := int32(sample * 8388607 * 0.5)
// Write to all channels
for ch := 0; ch < s.channels; ch++ {
samples[i*s.channels + ch] = pcmValue
}
}
return len(samples), nil
}
```
## Key concepts
1. **Sample format**: Use `int32` for 24-bit audio (range: -8388608 to 8388607)
2. **Interleaving**: For stereo (2 channels), samples are stored as [L, R, L, R, ...]
3. **Thread safety**: Use mutex if internal state is accessed from multiple goroutines
4. **Continuous playback**: Track `sampleIndex` to maintain phase continuity
## Use cases for custom sources
- **File streaming**: Read from MP3, FLAC, WAV files
- **Live input**: Capture from microphone or line-in
- **Synthesizers**: Generate music programmatically
- **Network streams**: Proxy audio from internet radio
- **Audio processing**: Apply effects, mixing, filtering
- **Testing**: Generate test signals for debugging
## Next steps
- Implement a file-based source using `pkg/audio/decode`
- Add audio effects (reverb, echo, filters)
- Create a source that mixes multiple input sources
- Implement a source that reads from an audio device

View File

@@ -0,0 +1,227 @@
// ABOUTME: Custom AudioSource implementation example
// ABOUTME: Demonstrates how to create custom audio sources for Sendspin
package main
import (
"flag"
"log"
"math"
"os"
"os/signal"
"sync"
"syscall"
"github.com/Sendspin/sendspin-go/pkg/sendspin"
)
// MultiToneSource generates multiple sine waves at different frequencies
// This demonstrates how to implement the AudioSource interface
type MultiToneSource struct {
frequencies []float64
sampleRate int
channels int
sampleIndex uint64
mu sync.Mutex
}
// NewMultiTone creates a source that mixes multiple frequencies
func NewMultiTone(sampleRate, channels int, frequencies []float64) *MultiToneSource {
return &MultiToneSource{
frequencies: frequencies,
sampleRate: sampleRate,
channels: channels,
}
}
// Read implements AudioSource.Read
// Generates PCM samples by mixing multiple sine waves
func (s *MultiToneSource) Read(samples []int32) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
numFrames := len(samples) / s.channels
for i := 0; i < numFrames; i++ {
// Calculate time for this sample
t := float64(s.sampleIndex+uint64(i)) / float64(s.sampleRate)
// Mix all frequencies together
var mixedSample float64
for _, freq := range s.frequencies {
mixedSample += math.Sin(2 * math.Pi * freq * t)
}
// Average the mixed signal
if len(s.frequencies) > 0 {
mixedSample /= float64(len(s.frequencies))
}
// Convert to 24-bit PCM (int32)
// Scale to 24-bit range with 50% volume to prevent clipping
const max24bit = 8388607 // 2^23 - 1
pcmValue := int32(mixedSample * max24bit * 0.5)
// Duplicate to all channels
for ch := 0; ch < s.channels; ch++ {
samples[i*s.channels+ch] = pcmValue
}
}
s.sampleIndex += uint64(numFrames)
return len(samples), nil
}
// SampleRate implements AudioSource.SampleRate
func (s *MultiToneSource) SampleRate() int {
return s.sampleRate
}
// Channels implements AudioSource.Channels
func (s *MultiToneSource) Channels() int {
return s.channels
}
// Metadata implements AudioSource.Metadata
func (s *MultiToneSource) Metadata() (title, artist, album string) {
return "Multi-Tone Test Signal", "Sendspin Examples", "Custom Sources"
}
// Close implements AudioSource.Close
func (s *MultiToneSource) Close() error {
return nil
}
// SweepSource generates a frequency sweep (chirp)
// Demonstrates time-varying audio generation
type SweepSource struct {
startFreq float64
endFreq float64
duration float64 // seconds
sampleRate int
channels int
sampleIndex uint64
mu sync.Mutex
}
// NewSweep creates a frequency sweep source
func NewSweep(sampleRate, channels int, startFreq, endFreq, duration float64) *SweepSource {
return &SweepSource{
startFreq: startFreq,
endFreq: endFreq,
duration: duration,
sampleRate: sampleRate,
channels: channels,
}
}
// Read implements AudioSource.Read
func (s *SweepSource) Read(samples []int32) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
numFrames := len(samples) / s.channels
for i := 0; i < numFrames; i++ {
t := float64(s.sampleIndex+uint64(i)) / float64(s.sampleRate)
// Loop the sweep
t = math.Mod(t, s.duration)
// Linear frequency sweep
progress := t / s.duration
freq := s.startFreq + (s.endFreq-s.startFreq)*progress
// Generate sine wave at current frequency
sample := math.Sin(2 * math.Pi * freq * t)
// Convert to 24-bit PCM
const max24bit = 8388607
pcmValue := int32(sample * max24bit * 0.5)
// Duplicate to all channels
for ch := 0; ch < s.channels; ch++ {
samples[i*s.channels+ch] = pcmValue
}
}
s.sampleIndex += uint64(numFrames)
return len(samples), nil
}
func (s *SweepSource) SampleRate() int { return s.sampleRate }
func (s *SweepSource) Channels() int { return s.channels }
func (s *SweepSource) Metadata() (string, string, string) {
return "Frequency Sweep", "Sendspin Examples", "Custom Sources"
}
func (s *SweepSource) Close() error { return nil }
func main() {
port := flag.Int("port", 8927, "Server port")
mode := flag.String("mode", "chord", "Source mode: chord, sweep, single")
sampleRate := flag.Int("rate", 192000, "Sample rate (Hz)")
channels := flag.Int("channels", 2, "Number of channels")
flag.Parse()
var source sendspin.AudioSource
// Create audio source based on mode
switch *mode {
case "chord":
// A major chord: A4, C#5, E5
frequencies := []float64{440.0, 554.37, 659.25}
source = NewMultiTone(*sampleRate, *channels, frequencies)
log.Printf("Creating A major chord: %v Hz", frequencies)
case "sweep":
// Sweep from 220 Hz (A3) to 880 Hz (A5) over 5 seconds
source = NewSweep(*sampleRate, *channels, 220.0, 880.0, 5.0)
log.Printf("Creating frequency sweep: 220 - 880 Hz over 5 seconds")
case "single":
// Single 440 Hz tone (same as basic example)
source = sendspin.NewTestTone(*sampleRate, *channels)
log.Printf("Creating single tone: 440 Hz")
default:
log.Fatalf("Invalid mode: %s (use: chord, sweep, single)", *mode)
}
config := sendspin.ServerConfig{
Port: *port,
Name: "Custom Source Example",
Source: source,
EnableMDNS: true,
Debug: false,
}
server, err := sendspin.NewServer(config)
if err != nil {
log.Fatalf("Failed to create server: %v", err)
}
log.Printf("Starting server on port %d...", *port)
log.Printf("Audio: %dHz, %d channels, 24-bit", *sampleRate, *channels)
errChan := make(chan error, 1)
go func() {
if err := server.Start(); err != nil {
errChan <- err
}
}()
log.Printf("\nServer running. Press Ctrl+C to stop")
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
select {
case <-sigChan:
log.Printf("Shutting down...")
case err := <-errChan:
log.Printf("Server error: %v", err)
}
server.Stop()
log.Printf("Server stopped")
}

44
third_party/sendspin-go/go.mod vendored Normal file
View File

@@ -0,0 +1,44 @@
module github.com/Sendspin/sendspin-go
go 1.24.1
require (
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/gen2brain/malgo v0.11.24
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/hajimehoshi/go-mp3 v0.3.4
github.com/hashicorp/mdns v1.0.6
github.com/mewkiz/flac v1.0.13
gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/icza/bitio v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d // indirect
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 // indirect
github.com/miekg/dns v1.1.55 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/mod v0.17.0 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sync v0.12.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.23.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
)

150
third_party/sendspin-go/go.sum vendored Normal file
View File

@@ -0,0 +1,150 @@
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/gen2brain/malgo v0.11.24 h1:hHcIJVfzWcEDHFdPl5Dl/CUSOjzOleY0zzAV8Kx+imE=
github.com/gen2brain/malgo v0.11.24/go.mod h1:f9TtuN7DVrXMiV/yIceMeWpvanyVzJQMlBecJFVMxww=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68=
github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo=
github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo=
github.com/hashicorp/mdns v1.0.6 h1:SV8UcjnQ/+C7KeJ/QeVD/mdN2EmzYfcGfufcuzxfCLQ=
github.com/hashicorp/mdns v1.0.6/go.mod h1:X4+yWh+upFECLOki1doUPaKpgNQII9gy4bUdCYKNhmM=
github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0=
github.com/icza/bitio v1.1.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A=
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k=
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mewkiz/flac v1.0.13 h1:6wF8rRQKBFW159Daqx6Ro7K5ZnlVhHUKfS5aTsC4oXs=
github.com/mewkiz/flac v1.0.13/go.mod h1:HfPYDA+oxjyuqMu2V+cyKcxF51KM6incpw5eZXmfA6k=
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d h1:IL2tii4jXLdhCeQN69HNzYYW1kl0meSG0wt5+sLwszU=
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d/go.mod h1:SIpumAnUWSy0q9RzKD3pyH3g1t5vdawUAPcW5tQrUtI=
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 h1:h8O1byDZ1uk6RUXMhj1QJU3VXFKXHDZxr4TXRPGeBa8=
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985/go.mod h1:uiPmbdUbdt1NkGApKl7htQjZ8S7XaGUAVulJUJ9v6q4=
github.com/miekg/dns v1.1.55 h1:GoQ4hpsj0nFLYe+bWiCToyrBEJXkQfOOIvFGFy0lEgo=
github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302 h1:xeVptzkP8BuJhoIjNizd2bRHfq9KB9HfOLZu90T04XM=
gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302/go.mod h1:/L5E7a21VWl8DeuCPKxQBdVG5cy+L0MRZ08B1wnqt7g=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

55
third_party/sendspin-go/install-deps.sh vendored Normal file
View File

@@ -0,0 +1,55 @@
#!/bin/bash
# ABOUTME: Dependency installation script for Resonate Protocol
# ABOUTME: Installs required audio libraries on macOS and Linux
set -e
echo "Installing Resonate Protocol dependencies..."
if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS
echo "Detected macOS"
# Check if Homebrew is installed
if ! command -v brew &> /dev/null; then
echo "Error: Homebrew is not installed. Please install it from https://brew.sh"
exit 1
fi
echo "Installing audio libraries via Homebrew..."
brew install opus
elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
# Linux
echo "Detected Linux"
if command -v apt-get &> /dev/null; then
# Debian/Ubuntu
echo "Installing audio libraries via apt..."
sudo apt-get update
sudo apt-get install -y libopus-dev
elif command -v dnf &> /dev/null; then
# Fedora/RHEL
echo "Installing audio libraries via dnf..."
sudo dnf install -y opus-devel
elif command -v pacman &> /dev/null; then
# Arch Linux
echo "Installing audio libraries via pacman..."
sudo pacman -S --noconfirm opus
else
echo "Error: Unsupported Linux distribution. Please install manually:"
echo " - libopus / opus-devel"
exit 1
fi
else
echo "Error: Unsupported operating system: $OSTYPE"
exit 1
fi
echo ""
echo "Dependencies installed successfully."
echo ""
echo "Note: builds use -tags=nolibopusfile by default (Makefile sets this),"
echo "so libopusfile is not required. If you build manually with raw 'go build'"
echo "and want to skip libopusfile yourself, run:"
echo " GOFLAGS=-tags=nolibopusfile go build ./..."

View File

@@ -0,0 +1,277 @@
// ABOUTME: mDNS service discovery for Sendspin Protocol
// ABOUTME: Handles both advertisement (server-initiated) and browsing (client-initiated)
package discovery
import (
"context"
"fmt"
"io"
"log"
"net"
"strings"
"time"
"github.com/hashicorp/mdns"
)
// silentLogger discards hashicorp/mdns internal logs (e.g. "[INFO] mdns: Closing client")
var silentLogger = log.New(io.Discard, "", 0)
type Config struct {
ServiceName string
Port int
ServerMode bool // If true, advertise as _sendspin-server._tcp, otherwise _sendspin._tcp
}
type Manager struct {
config Config
ctx context.Context
cancel context.CancelFunc
servers chan *ServerInfo
clients chan *ClientInfo
}
type ServerInfo struct {
Name string
Host string
Port int
}
// ClientInfo describes a discovered client (player) advertised via
// _sendspin._tcp.local.
type ClientInfo struct {
Instance string // fully-qualified mDNS instance name (stable dedupe key)
Name string // friendly name from TXT "name=" or falls back to Instance
Host string // IPv4 address as a string
Port int
Path string // WebSocket path from TXT "path=" (default "/sendspin")
}
// clientInfoFromEntry converts an mdns.ServiceEntry into a ClientInfo.
// Returns nil when the entry lacks a usable IPv4 address or port, or when
// the entry does not belong to the _sendspin._tcp service (hashicorp/mdns
// is promiscuous and forwards any multicast response received during the
// query window, not just matches to the queried service).
func clientInfoFromEntry(entry *mdns.ServiceEntry) *ClientInfo {
if entry == nil || entry.AddrV4 == nil || entry.Port == 0 {
return nil
}
if !strings.Contains(entry.Name, "._sendspin._tcp.") {
return nil
}
txt := parseTXT(entry.InfoFields)
path := txt["path"]
if path == "" {
path = "/sendspin"
}
name := txt["name"]
if name == "" {
name = entry.Name
}
return &ClientInfo{
Instance: entry.Name,
Name: name,
Host: entry.AddrV4.String(),
Port: entry.Port,
Path: path,
}
}
func NewManager(config Config) *Manager {
ctx, cancel := context.WithCancel(context.Background())
return &Manager{
config: config,
ctx: ctx,
cancel: cancel,
servers: make(chan *ServerInfo, 10),
clients: make(chan *ClientInfo, 10),
}
}
func (m *Manager) Advertise() error {
ips, err := getLocalIPs()
if err != nil {
return fmt.Errorf("failed to get local IPs: %w", err)
}
serviceType := "_sendspin._tcp"
if m.config.ServerMode {
serviceType = "_sendspin-server._tcp"
}
service, err := mdns.NewMDNSService(
m.config.ServiceName,
serviceType,
"",
"",
m.config.Port,
ips,
[]string{"path=/sendspin"},
)
if err != nil {
return fmt.Errorf("failed to create service: %w", err)
}
server, err := mdns.NewServer(&mdns.Config{Zone: service, Logger: silentLogger})
if err != nil {
return fmt.Errorf("failed to create mdns server: %w", err)
}
log.Printf("Advertising mDNS service: %s on port %d (type: %s)", m.config.ServiceName, m.config.Port, serviceType)
go func() {
<-m.ctx.Done()
server.Shutdown()
}()
return nil
}
func (m *Manager) Browse() error {
go m.browseLoop()
return nil
}
func (m *Manager) browseLoop() {
for {
select {
case <-m.ctx.Done():
return
default:
}
entries := make(chan *mdns.ServiceEntry, 10)
go func() {
for entry := range entries {
// hashicorp/mdns is promiscuous: any multicast response arriving
// on the socket during the query window is forwarded, regardless
// of whether it matches Service. Filter by instance-name suffix
// so we don't dial Google Cast, ADB, etc.
if !strings.Contains(entry.Name, "._sendspin-server._tcp.") {
continue
}
if entry.AddrV4 == nil || entry.Port == 0 {
continue
}
server := &ServerInfo{
Name: entry.Name,
Host: entry.AddrV4.String(),
Port: entry.Port,
}
log.Printf("Discovered server: %s at %s:%d", server.Name, server.Host, server.Port)
select {
case m.servers <- server:
case <-m.ctx.Done():
return
}
}
}()
params := &mdns.QueryParam{
Service: "_sendspin-server._tcp",
Domain: "local",
Timeout: 3 * time.Second,
Entries: entries,
Logger: silentLogger,
}
mdns.Query(params)
close(entries)
}
}
func (m *Manager) Servers() <-chan *ServerInfo {
return m.servers
}
func (m *Manager) Stop() {
m.cancel()
}
// BrowseClients searches for Sendspin clients advertising _sendspin._tcp.
func (m *Manager) BrowseClients() error {
go m.browseClientsLoop()
return nil
}
func (m *Manager) Clients() <-chan *ClientInfo {
return m.clients
}
func (m *Manager) browseClientsLoop() {
for {
select {
case <-m.ctx.Done():
return
default:
}
entries := make(chan *mdns.ServiceEntry, 10)
go func() {
for entry := range entries {
info := clientInfoFromEntry(entry)
if info == nil {
continue
}
log.Printf("Discovered client: %s at %s:%d%s",
info.Name, info.Host, info.Port, info.Path)
select {
case m.clients <- info:
case <-m.ctx.Done():
return
}
}
}()
params := &mdns.QueryParam{
Service: "_sendspin._tcp",
Domain: "local",
Timeout: 3 * time.Second,
Entries: entries,
Logger: silentLogger,
}
if err := mdns.Query(params); err != nil {
log.Printf("mdns query error: %v", err)
}
close(entries)
}
}
func getLocalIPs() ([]net.IP, error) {
var ips []net.IP
ifaces, err := net.Interfaces()
if err != nil {
return nil, err
}
for _, iface := range ifaces {
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
if ipnet.IP.To4() != nil {
ips = append(ips, ipnet.IP)
}
}
}
}
return ips, nil
}

View File

@@ -0,0 +1,142 @@
// ABOUTME: Tests for mDNS discovery
// ABOUTME: Tests service advertisement and discovery
package discovery
import (
"net"
"testing"
"github.com/hashicorp/mdns"
)
func TestNewManager(t *testing.T) {
config := Config{
ServiceName: "Test Player",
Port: 8927,
}
mgr := NewManager(config)
if mgr == nil {
t.Fatal("expected manager to be created")
}
}
func TestClientInfoFromEntry(t *testing.T) {
tests := []struct {
name string
entry *mdns.ServiceEntry
want *ClientInfo
}{
{
name: "full entry with path and name",
entry: &mdns.ServiceEntry{
Name: "living-room._sendspin._tcp.local.",
Host: "living-room.local.",
AddrV4: net.ParseIP("192.168.1.42"),
Port: 8928,
InfoFields: []string{"path=/sendspin", "name=Living Room"},
},
want: &ClientInfo{
Instance: "living-room._sendspin._tcp.local.",
Name: "Living Room",
Host: "192.168.1.42",
Port: 8928,
Path: "/sendspin",
},
},
{
name: "missing path defaults to /sendspin",
entry: &mdns.ServiceEntry{
Name: "kitchen._sendspin._tcp.local.",
AddrV4: net.ParseIP("192.168.1.43"),
Port: 8928,
InfoFields: []string{"name=Kitchen"},
},
want: &ClientInfo{
Instance: "kitchen._sendspin._tcp.local.",
Name: "Kitchen",
Host: "192.168.1.43",
Port: 8928,
Path: "/sendspin",
},
},
{
name: "missing name falls back to entry.Name",
entry: &mdns.ServiceEntry{
Name: "bedroom._sendspin._tcp.local.",
AddrV4: net.ParseIP("192.168.1.44"),
Port: 8928,
InfoFields: []string{"path=/sendspin"},
},
want: &ClientInfo{
Instance: "bedroom._sendspin._tcp.local.",
Name: "bedroom._sendspin._tcp.local.",
Host: "192.168.1.44",
Port: 8928,
Path: "/sendspin",
},
},
{
name: "no IPv4 address returns nil",
entry: &mdns.ServiceEntry{
Name: "ipv6-only._sendspin._tcp.local.",
Port: 8928,
InfoFields: []string{"path=/sendspin"},
},
want: nil,
},
{
name: "zero port returns nil",
entry: &mdns.ServiceEntry{
Name: "bad._sendspin._tcp.local.",
AddrV4: net.ParseIP("192.168.1.45"),
Port: 0,
InfoFields: []string{"path=/sendspin"},
},
want: nil,
},
{
// hashicorp/mdns is promiscuous and forwards any multicast
// response, not just matches to the queried service. Entries
// for other services must be filtered out or the server would
// dial Google Cast, ADB, etc. as if they were sendspin clients.
name: "non-sendspin service is filtered out",
entry: &mdns.ServiceEntry{
Name: "SHIELD._googlecast._tcp.local.",
AddrV4: net.ParseIP("192.168.1.50"),
Port: 8009,
InfoFields: []string{},
},
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := clientInfoFromEntry(tt.entry)
if (got == nil) != (tt.want == nil) {
t.Fatalf("clientInfoFromEntry nil-ness mismatch: got %+v, want %+v", got, tt.want)
}
if got == nil {
return
}
if *got != *tt.want {
t.Errorf("clientInfoFromEntry = %+v, want %+v", got, tt.want)
}
})
}
}
func TestManagerExposesClientsChannel(t *testing.T) {
mgr := NewManager(Config{ServiceName: "Test", Port: 8928})
ch := mgr.Clients()
if ch == nil {
t.Fatal("Clients() returned nil channel")
}
// channel must be receive-only or bidirectional — just confirm we can select on it
select {
case <-ch:
t.Fatal("unexpected value on empty clients channel")
default:
}
}

View File

@@ -0,0 +1,25 @@
// ABOUTME: Pure TXT record parsing for mDNS service entries
// ABOUTME: Converts []string of "key=value" entries to map[string]string
package discovery
import "strings"
// parseTXT converts an mDNS TXT record slice (each element "key=value")
// into a map. Empty strings are ignored. Keys without '=' are stored
// with an empty value. When a key appears multiple times, the last
// occurrence wins.
func parseTXT(fields []string) map[string]string {
out := make(map[string]string, len(fields))
for _, f := range fields {
if f == "" {
continue
}
if idx := strings.Index(f, "="); idx >= 0 {
out[f[:idx]] = f[idx+1:]
} else {
out[f] = ""
}
}
return out
}

View File

@@ -0,0 +1,62 @@
// ABOUTME: Tests for TXT record parsing
// ABOUTME: Pure parser, no mDNS dependency
package discovery
import (
"reflect"
"testing"
)
func TestParseTXT(t *testing.T) {
tests := []struct {
name string
fields []string
want map[string]string
}{
{
name: "empty",
fields: nil,
want: map[string]string{},
},
{
name: "single key",
fields: []string{"path=/sendspin"},
want: map[string]string{"path": "/sendspin"},
},
{
name: "multiple keys",
fields: []string{"path=/sendspin", "name=Living Room"},
want: map[string]string{"path": "/sendspin", "name": "Living Room"},
},
{
name: "key without value",
fields: []string{"flag"},
want: map[string]string{"flag": ""},
},
{
name: "value contains equals",
fields: []string{"token=abc=def=ghi"},
want: map[string]string{"token": "abc=def=ghi"},
},
{
name: "empty string ignored",
fields: []string{"", "path=/sendspin"},
want: map[string]string{"path": "/sendspin"},
},
{
name: "duplicate key: last wins",
fields: []string{"path=/old", "path=/new"},
want: map[string]string{"path": "/new"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseTXT(tt.fields)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseTXT(%v) = %v, want %v", tt.fields, got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,313 @@
// ABOUTME: Audio streaming engine for Sendspin server
// ABOUTME: Generates test tones and streams timestamped audio to clients
package server
import (
"fmt"
"log"
"sync"
"time"
"github.com/Sendspin/sendspin-go/pkg/protocol"
)
const (
// Audio format constants - Hi-Res Audio (192kHz/24-bit)
DefaultSampleRate = 192000
DefaultChannels = 2
DefaultBitDepth = 24
// Chunk timing
ChunkDurationMs = 20 // 20ms chunks
// Buffering
BufferAheadMs = 500 // Send audio 500ms ahead for PCM
BufferAheadOpusMs = 1000 // Send audio 1000ms ahead for Opus (more processing overhead)
)
// AudioEngine manages audio generation and streaming
type AudioEngine struct {
server *Server
// Active clients
clients map[string]*Client
clientsMu sync.RWMutex
// Audio source (file or test tone)
source AudioSource
sampleBuf []int32 // pre-allocated sample buffer reused each chunk
stopChan chan struct{}
stopOnce sync.Once // Ensure Stop() is only called once
}
func NewAudioEngine(server *Server) (*AudioEngine, error) {
source, err := NewAudioSource(server.config.AudioFile)
if err != nil {
return nil, fmt.Errorf("failed to create audio source: %w", err)
}
// Pre-allocate sample buffer for the 20ms chunk generation loop
chunkSamples := (source.SampleRate() * ChunkDurationMs) / 1000
totalSamples := chunkSamples * source.Channels()
return &AudioEngine{
server: server,
clients: make(map[string]*Client),
source: source,
sampleBuf: make([]int32, totalSamples),
stopChan: make(chan struct{}),
}, nil
}
func (e *AudioEngine) Start() {
log.Printf("Audio engine starting")
ticker := time.NewTicker(time.Duration(ChunkDurationMs) * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ticker.C:
e.generateAndSendChunk()
case <-e.stopChan:
log.Printf("Audio engine stopping")
return
}
}
}
func (e *AudioEngine) Stop() {
e.stopOnce.Do(func() {
close(e.stopChan)
if e.source != nil {
if err := e.source.Close(); err != nil {
log.Printf("Error closing audio source: %v", err)
}
}
})
}
func (e *AudioEngine) AddClient(client *Client) {
e.clientsMu.Lock()
defer e.clientsMu.Unlock()
codec := e.negotiateCodec(client)
var opusEncoder *OpusEncoder
var resampler *Resampler
sourceRate := e.source.SampleRate()
switch codec {
case "opus":
// Opus requires 48kHz - create resampler if source rate is different
if sourceRate != 48000 {
resampler = NewResampler(sourceRate, 48000, e.source.Channels())
log.Printf("Created resampler: %dHz → 48kHz for Opus (client: %s)", sourceRate, client.Name)
}
// Create Opus encoder (always at 48kHz)
opusChunkSamples := (48000 * ChunkDurationMs) / 1000
encoder, err := NewOpusEncoder(48000, e.source.Channels(), opusChunkSamples)
if err != nil {
log.Printf("Failed to create Opus encoder for %s, falling back to PCM: %v", client.Name, err)
codec = "pcm"
resampler = nil // Clear resampler if Opus encoder failed
} else {
opusEncoder = encoder
}
case "flac":
// FLAC is a file format, not a streaming codec
// It requires headers at the start and can't be split into independent chunks
// Fall back to PCM for lossless streaming
log.Printf("FLAC streaming not supported for %s, using PCM for lossless audio", client.Name)
codec = "pcm"
}
client.mu.Lock()
client.Codec = codec
client.OpusEncoder = opusEncoder
client.Resampler = resampler
client.mu.Unlock()
e.clients[client.ID] = client
log.Printf("Audio engine: added client %s with codec %s (format: %dHz/%dbit/%dch)",
client.Name, codec, e.source.SampleRate(), DefaultBitDepth, e.source.Channels())
streamStart := protocol.StreamStart{
Player: &protocol.StreamStartPlayer{
Codec: codec,
SampleRate: e.source.SampleRate(),
Channels: e.source.Channels(),
BitDepth: DefaultBitDepth,
},
}
msg := protocol.Message{
Type: "stream/start",
Payload: streamStart,
}
select {
case client.sendChan <- msg:
default:
log.Printf("Warning: Could not send stream/start to %s (channel full)", client.Name)
}
title, artist, album := e.source.Metadata()
metadata := protocol.StreamMetadata{
Title: title,
Artist: artist,
Album: album,
}
metaMsg := protocol.Message{
Type: "stream/metadata",
Payload: metadata,
}
select {
case client.sendChan <- metaMsg:
default:
log.Printf("Warning: Could not send metadata to %s (channel full)", client.Name)
}
}
func (e *AudioEngine) RemoveClient(client *Client) {
e.clientsMu.Lock()
defer e.clientsMu.Unlock()
client.mu.Lock()
if client.OpusEncoder != nil {
client.OpusEncoder.Close()
client.OpusEncoder = nil
}
if client.Resampler != nil {
client.Resampler = nil
}
client.mu.Unlock()
delete(e.clients, client.ID)
log.Printf("Audio engine: removed client %s", client.Name)
}
// negotiateCodec selects the best codec based on client capabilities.
// With resampling support, we prefer PCM for native-rate hi-res clients and
// Opus (with resampling) for everything else to save bandwidth.
func (e *AudioEngine) negotiateCodec(client *Client) string {
if client.Capabilities == nil {
return "pcm"
}
sourceRate := e.source.SampleRate()
// Strategy:
// 1. If client supports PCM at native rate → use PCM (lossless hi-res)
// 2. If client supports Opus → use Opus with resampling (bandwidth efficient)
// 3. Otherwise → fall back to PCM
for _, format := range client.Capabilities.SupportedFormats {
if format.Codec == "pcm" && format.SampleRate == sourceRate && format.BitDepth == DefaultBitDepth {
return "pcm"
}
}
for _, format := range client.Capabilities.SupportedFormats {
if format.Codec == "opus" {
return "opus"
}
}
return "pcm"
}
func (e *AudioEngine) generateAndSendChunk() {
currentTime := e.server.getClockMicros()
samples := e.sampleBuf
n, err := e.source.Read(samples)
if err != nil {
log.Printf("Error reading audio source: %v", err)
return
}
e.clientsMu.RLock()
defer e.clientsMu.RUnlock()
for _, client := range e.clients {
var audioData []byte
var encodeErr error
client.mu.RLock()
codec := client.Codec
opusEncoder := client.OpusEncoder
resampler := client.Resampler
client.mu.RUnlock()
switch codec {
case "opus":
if opusEncoder != nil {
samplesToEncode := samples[:n]
if resampler != nil {
outputSamples := resampler.OutputSamplesNeeded(len(samplesToEncode))
resampled := make([]int32, outputSamples)
samplesWritten := resampler.Resample(samplesToEncode, resampled)
samplesToEncode = resampled[:samplesWritten]
}
// Convert int32 to int16 for Opus (Opus only supports 16-bit)
samples16 := convertToInt16(samplesToEncode)
audioData, encodeErr = opusEncoder.Encode(samples16)
if encodeErr != nil {
log.Printf("Opus encode error for %s: %v", client.Name, encodeErr)
continue
}
} else {
log.Printf("Warning: Client %s has opus codec but no encoder", client.Name)
continue
}
case "pcm":
audioData = encodePCM(samples[:n])
default:
// Unknown codec, fall back to PCM
log.Printf("Warning: Unknown codec %s for client %s, using PCM", codec, client.Name)
audioData = encodePCM(samples[:n])
}
bufferAhead := BufferAheadMs
if codec == "opus" {
bufferAhead = BufferAheadOpusMs
}
playbackTime := currentTime + (int64(bufferAhead) * 1000)
chunk := CreateAudioChunk(playbackTime, audioData)
if err := e.server.sendBinary(client, chunk); err != nil {
log.Printf("Error sending audio to %s: %v", client.Name, err)
}
}
}
// convertToInt16 converts int32 samples to int16 by dropping the lowest byte
// (24-bit → 16-bit range shift required by the Opus encoder).
func convertToInt16(samples []int32) []int16 {
result := make([]int16, len(samples))
for i, s := range samples {
result[i] = int16(s >> 8)
}
return result
}
// encodePCM packs int32 samples as 24-bit little-endian PCM (3 bytes per sample).
func encodePCM(samples []int32) []byte {
output := make([]byte, len(samples)*3)
for i, sample := range samples {
output[i*3] = byte(sample)
output[i*3+1] = byte(sample >> 8)
output[i*3+2] = byte(sample >> 16)
}
return output
}

View File

@@ -0,0 +1,112 @@
// ABOUTME: Regression tests for AudioEngine codec negotiation and resampler setup
// ABOUTME: Covers hi-res source + Opus client path (github issue #2)
package server
import (
"testing"
"github.com/Sendspin/sendspin-go/pkg/protocol"
)
// fakeAudioSource is a minimal AudioSource used to drive AudioEngine under test.
type fakeAudioSource struct {
sampleRate int
channels int
}
func (f *fakeAudioSource) Read(samples []int32) (int, error) { return len(samples), nil }
func (f *fakeAudioSource) SampleRate() int { return f.sampleRate }
func (f *fakeAudioSource) Channels() int { return f.channels }
func (f *fakeAudioSource) Metadata() (string, string, string) { return "", "", "" }
func (f *fakeAudioSource) Close() error { return nil }
func newTestEngine(sampleRate, channels int) *AudioEngine {
return &AudioEngine{
clients: make(map[string]*Client),
source: &fakeAudioSource{sampleRate: sampleRate, channels: channels},
stopChan: make(chan struct{}),
}
}
func newTestClient(id string, formats []protocol.AudioFormat) *Client {
return &Client{
ID: id,
Name: id,
Capabilities: &protocol.PlayerV1Support{
SupportedFormats: formats,
},
sendChan: make(chan interface{}, 4),
done: make(chan struct{}),
}
}
// TestAddClient_HiResSource_OpusClient is a regression test for issue #2:
// a 192kHz source with an Opus-capable client must negotiate Opus and attach
// a resampler that converts 192kHz -> 48kHz. Previously the server fell back
// to PCM in this case, causing ~36x bandwidth usage.
func TestAddClient_HiResSource_OpusClient(t *testing.T) {
engine := newTestEngine(192000, 2)
client := newTestClient("opus-client", []protocol.AudioFormat{
{Codec: "opus", Channels: 2, SampleRate: 48000, BitDepth: 16},
})
engine.AddClient(client)
defer engine.RemoveClient(client)
if client.Codec != "opus" {
t.Fatalf("expected codec %q, got %q", "opus", client.Codec)
}
if client.OpusEncoder == nil {
t.Fatal("expected OpusEncoder to be attached for 192kHz source + opus client")
}
if client.Resampler == nil {
t.Fatal("expected Resampler to be attached when source rate != 48kHz")
}
}
// TestAddClient_HiResSource_PCMNativeClient verifies the lossless hi-res
// path: a PCM client advertising the exact source rate should receive PCM
// with no resampler, taking precedence over Opus bandwidth savings.
func TestAddClient_HiResSource_PCMNativeClient(t *testing.T) {
engine := newTestEngine(192000, 2)
client := newTestClient("pcm-client", []protocol.AudioFormat{
{Codec: "pcm", Channels: 2, SampleRate: 192000, BitDepth: DefaultBitDepth},
{Codec: "opus", Channels: 2, SampleRate: 48000, BitDepth: 16},
})
engine.AddClient(client)
defer engine.RemoveClient(client)
if client.Codec != "pcm" {
t.Fatalf("expected codec %q, got %q", "pcm", client.Codec)
}
if client.OpusEncoder != nil {
t.Fatal("expected no OpusEncoder for native-rate PCM client")
}
if client.Resampler != nil {
t.Fatal("expected no Resampler for native-rate PCM client")
}
}
// TestAddClient_NativeOpusSource verifies the no-resample Opus path: when
// the source is already 48kHz, an Opus client should get an encoder but
// no resampler.
func TestAddClient_NativeOpusSource(t *testing.T) {
engine := newTestEngine(48000, 2)
client := newTestClient("opus-native", []protocol.AudioFormat{
{Codec: "opus", Channels: 2, SampleRate: 48000, BitDepth: 16},
})
engine.AddClient(client)
defer engine.RemoveClient(client)
if client.Codec != "opus" {
t.Fatalf("expected codec %q, got %q", "opus", client.Codec)
}
if client.OpusEncoder == nil {
t.Fatal("expected OpusEncoder to be attached")
}
if client.Resampler != nil {
t.Fatal("expected no Resampler when source rate == 48kHz")
}
}

View File

@@ -0,0 +1,548 @@
// ABOUTME: Audio source abstraction for streaming from files or generating test tones
// ABOUTME: Supports MP3, FLAC, WAV files with automatic decoding
package server
import (
"bufio"
"encoding/binary"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/hajimehoshi/go-mp3"
"github.com/mewkiz/flac"
)
// AudioSource provides PCM audio samples
type AudioSource interface {
// Read reads PCM samples into the buffer (int32 for 24-bit support). Returns number of samples read or error.
Read(samples []int32) (int, error)
// SampleRate returns the sample rate of the audio
SampleRate() int
// Channels returns the number of channels
Channels() int
// Metadata returns title, artist, album
Metadata() (title, artist, album string)
// Close closes the audio source
Close() error
}
// NewAudioSource creates an audio source from a file path or HTTP URL
// If path is empty, returns a test tone generator
// Automatically resamples to 48kHz if needed for Opus compatibility
func NewAudioSource(pathOrURL string) (AudioSource, error) {
if pathOrURL == "" {
return NewTestToneSource(), nil
}
var source AudioSource
var err error
if strings.HasPrefix(pathOrURL, "http://") || strings.HasPrefix(pathOrURL, "https://") {
if strings.Contains(pathOrURL, ".m3u8") {
log.Printf("Streaming from HLS URL: %s", pathOrURL)
source, err = NewFFmpegSource(pathOrURL)
if err != nil {
return nil, err
}
} else {
log.Printf("Streaming from HTTP URL: %s", pathOrURL)
source, err = NewHTTPMP3Source(pathOrURL)
if err != nil {
return nil, err
}
}
} else {
if _, err := os.Stat(pathOrURL); os.IsNotExist(err) {
return nil, fmt.Errorf("audio file not found: %s", pathOrURL)
}
ext := strings.ToLower(filepath.Ext(pathOrURL))
switch ext {
case ".mp3":
source, err = NewMP3Source(pathOrURL)
if err != nil {
return nil, err
}
case ".flac":
source, err = NewFLACSource(pathOrURL)
if err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("unsupported audio format: %s (supported: .mp3, .flac)", ext)
}
}
// Note: We no longer auto-resample here. Sources are kept at native sample rate.
// If Opus encoding is needed and source isn't 48kHz, resampling happens per-client in audio engine.
// This allows PCM clients to receive hi-res audio at native rates!
return source, nil
}
// MP3Source reads from an MP3 file
type MP3Source struct {
file *os.File
decoder *mp3.Decoder
sampleRate int
channels int
title string
artist string
album string
}
// NewMP3Source creates a new MP3 audio source
func NewMP3Source(filePath string) (*MP3Source, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("failed to open MP3 file: %w", err)
}
decoder, err := mp3.NewDecoder(f)
if err != nil {
f.Close()
return nil, fmt.Errorf("failed to decode MP3: %w", err)
}
filename := filepath.Base(filePath)
title := strings.TrimSuffix(filename, filepath.Ext(filename))
log.Printf("Loaded MP3: %s (sample rate: %d Hz)", title, decoder.SampleRate())
return &MP3Source{
file: f,
decoder: decoder,
sampleRate: decoder.SampleRate(),
channels: 2, // MP3 decoder outputs stereo
title: title,
artist: "Unknown Artist",
album: "Unknown Album",
}, nil
}
func (s *MP3Source) Read(samples []int32) (int, error) {
numBytes := len(samples) * 2 // int16 = 2 bytes per sample
buf := make([]byte, numBytes)
n, err := s.decoder.Read(buf)
if err != nil && err != io.EOF {
return 0, err
}
// Convert bytes to int16, then scale to 24-bit range
numSamples := n / 2
for i := 0; i < numSamples; i++ {
sample16 := int16(binary.LittleEndian.Uint16(buf[i*2 : i*2+2]))
// Left-shift by 8 to convert 16-bit range to 24-bit range
// Example: 32767 (max 16-bit) << 8 = 8388352 (near max 24-bit 8388607)
samples[i] = int32(sample16) << 8
}
if err == io.EOF {
if _, seekErr := s.file.Seek(0, 0); seekErr != nil {
return numSamples, fmt.Errorf("failed to seek to start: %w", seekErr)
}
newDecoder, decErr := mp3.NewDecoder(s.file)
if decErr != nil {
return numSamples, fmt.Errorf("failed to create new decoder: %w", decErr)
}
s.decoder = newDecoder
}
return numSamples, nil
}
func (s *MP3Source) SampleRate() int { return s.sampleRate }
func (s *MP3Source) Channels() int { return s.channels }
func (s *MP3Source) Metadata() (string, string, string) {
return s.title, s.artist, s.album
}
func (s *MP3Source) Close() error {
return s.file.Close()
}
// FLACSource reads from a FLAC file
type FLACSource struct {
file *os.File
stream *flac.Stream
sampleRate int
channels int
bitDepth int
title string
artist string
album string
// Buffer for partial frames (FLAC frames may not align with chunk boundaries)
frameBuffer []int32
frameBufferPos int
}
// NewFLACSource creates a new FLAC audio source
func NewFLACSource(filePath string) (*FLACSource, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("failed to open FLAC file: %w", err)
}
stream, err := flac.New(f)
if err != nil {
f.Close()
return nil, fmt.Errorf("failed to decode FLAC: %w", err)
}
info := stream.Info
sampleRate := int(info.SampleRate)
channels := int(info.NChannels)
bitDepth := int(info.BitsPerSample)
filename := filepath.Base(filePath)
title := strings.TrimSuffix(filename, filepath.Ext(filename))
log.Printf("Loaded FLAC: %s (sample rate: %d Hz, channels: %d, bit depth: %d)",
title, sampleRate, channels, bitDepth)
return &FLACSource{
file: f,
stream: stream,
sampleRate: sampleRate,
channels: channels,
bitDepth: bitDepth,
title: title,
artist: "Unknown Artist",
album: "Unknown Album",
}, nil
}
func (s *FLACSource) Read(samples []int32) (int, error) {
samplesRead := 0
// Drain any buffered samples from a previous partial frame before reading more
if s.frameBuffer != nil && s.frameBufferPos < len(s.frameBuffer) {
available := len(s.frameBuffer) - s.frameBufferPos
toCopy := len(samples)
if toCopy > available {
toCopy = available
}
copy(samples[samplesRead:], s.frameBuffer[s.frameBufferPos:s.frameBufferPos+toCopy])
samplesRead += toCopy
s.frameBufferPos += toCopy
if samplesRead >= len(samples) {
return samplesRead, nil
}
if s.frameBufferPos >= len(s.frameBuffer) {
s.frameBuffer = nil
s.frameBufferPos = 0
}
}
for samplesRead < len(samples) {
frame, err := s.stream.ParseNext()
if err != nil {
if err == io.EOF {
if _, seekErr := s.file.Seek(0, 0); seekErr != nil {
return samplesRead, fmt.Errorf("failed to seek to start: %w", seekErr)
}
newStream, decErr := flac.New(s.file)
if decErr != nil {
return samplesRead, fmt.Errorf("failed to create new stream: %w", decErr)
}
s.stream = newStream
s.frameBuffer = nil
s.frameBufferPos = 0
continue
}
return samplesRead, err
}
frameSize := int(frame.BlockSize) * s.channels
frameSamples := make([]int32, frameSize)
frameIdx := 0
for i := 0; i < int(frame.BlockSize); i++ {
for ch := 0; ch < s.channels; ch++ {
sample := frame.Subframes[ch].Samples[i]
// Convert to int32 24-bit range
var converted int32
if s.bitDepth == 16 {
// Convert 16-bit to 24-bit range
converted = sample << 8
} else if s.bitDepth == 24 {
// Already 24-bit, use directly
converted = sample
} else {
// For other bit depths, scale to 24-bit range
shift := s.bitDepth - 24
if shift > 0 {
converted = sample >> shift
} else {
converted = sample << -shift
}
}
frameSamples[frameIdx] = converted
frameIdx++
}
}
remaining := len(samples) - samplesRead
toCopy := frameSize
if toCopy > remaining {
toCopy = remaining
}
copy(samples[samplesRead:], frameSamples[:toCopy])
samplesRead += toCopy
// Buffer leftover samples — FLAC frames don't align with chunk boundaries
if toCopy < frameSize {
s.frameBuffer = frameSamples
s.frameBufferPos = toCopy
break
}
}
return samplesRead, nil
}
func (s *FLACSource) SampleRate() int { return s.sampleRate }
func (s *FLACSource) Channels() int { return s.channels }
func (s *FLACSource) Metadata() (string, string, string) {
return s.title, s.artist, s.album
}
func (s *FLACSource) Close() error {
return s.file.Close()
}
// HTTPMP3Source streams MP3 from an HTTP URL
type HTTPMP3Source struct {
url string
response *http.Response
decoder *mp3.Decoder
sampleRate int
channels int
title string
}
func NewHTTPMP3Source(url string) (*HTTPMP3Source, error) {
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(url)
if err != nil {
return nil, fmt.Errorf("failed to fetch HTTP stream: %w", err)
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, fmt.Errorf("HTTP error: %s", resp.Status)
}
decoder, err := mp3.NewDecoder(resp.Body)
if err != nil {
resp.Body.Close()
return nil, fmt.Errorf("failed to decode MP3 stream: %w", err)
}
log.Printf("Streaming MP3 from HTTP: %s (sample rate: %d Hz)", url, decoder.SampleRate())
return &HTTPMP3Source{
url: url,
response: resp,
decoder: decoder,
sampleRate: decoder.SampleRate(),
channels: 2, // MP3 decoder outputs stereo
title: "HTTP Stream",
}, nil
}
func (s *HTTPMP3Source) Read(samples []int32) (int, error) {
numBytes := len(samples) * 2 // int16 = 2 bytes
buf := make([]byte, numBytes)
n, err := s.decoder.Read(buf)
if err != nil {
return 0, err // Don't loop HTTP streams, just end on EOF
}
// Convert bytes to int16, then scale to 24-bit range
numSamples := n / 2
for i := 0; i < numSamples; i++ {
sample16 := int16(binary.LittleEndian.Uint16(buf[i*2 : i*2+2]))
// Left-shift by 8 to convert 16-bit range to 24-bit range
samples[i] = int32(sample16) << 8
}
return numSamples, nil
}
func (s *HTTPMP3Source) SampleRate() int { return s.sampleRate }
func (s *HTTPMP3Source) Channels() int { return s.channels }
func (s *HTTPMP3Source) Metadata() (string, string, string) {
return s.title, "HTTP Stream", ""
}
func (s *HTTPMP3Source) Close() error {
if s.response != nil {
return s.response.Body.Close()
}
return nil
}
// FFmpegSource streams audio from any URL/format using ffmpeg
// Supports HLS (.m3u8), DASH, and other streaming protocols
type FFmpegSource struct {
url string
cmd *exec.Cmd
stdout io.ReadCloser
reader *bufio.Reader
sampleRate int
channels int
title string
}
// NewFFmpegSource creates an ffmpeg-backed source for HLS and other streaming URLs.
func NewFFmpegSource(url string) (*FFmpegSource, error) {
// Validate URL scheme to prevent command injection
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
return nil, fmt.Errorf("unsupported URL scheme: only http and https are allowed")
}
// Check if ffmpeg is available
if _, err := exec.LookPath("ffmpeg"); err != nil {
return nil, fmt.Errorf("ffmpeg not found in PATH: %w (install with: brew install ffmpeg)", err)
}
sampleRate := 48000
channels := 2
cmd := exec.Command("ffmpeg",
"-loglevel", "error", // Only show errors
"-i", url,
"-f", "s16le",
"-ar", fmt.Sprintf("%d", sampleRate),
"-ac", fmt.Sprintf("%d", channels),
"-")
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("failed to get ffmpeg stdout: %w", err)
}
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("failed to start ffmpeg: %w", err)
}
log.Printf("Streaming via ffmpeg: %s (sample rate: %d Hz, channels: %d)", url, sampleRate, channels)
return &FFmpegSource{
url: url,
cmd: cmd,
stdout: stdout,
reader: bufio.NewReader(stdout),
sampleRate: sampleRate,
channels: channels,
title: "Live Stream",
}, nil
}
func (s *FFmpegSource) Read(samples []int32) (int, error) {
numBytes := len(samples) * 2 // int16 = 2 bytes
buf := make([]byte, numBytes)
n, err := io.ReadFull(s.reader, buf)
if err != nil {
return 0, err
}
// Convert bytes to int16, then scale to 24-bit range
numSamples := n / 2
for i := 0; i < numSamples; i++ {
sample16 := int16(binary.LittleEndian.Uint16(buf[i*2 : i*2+2]))
// Left-shift by 8 to convert 16-bit range to 24-bit range
samples[i] = int32(sample16) << 8
}
return numSamples, nil
}
func (s *FFmpegSource) SampleRate() int { return s.sampleRate }
func (s *FFmpegSource) Channels() int { return s.channels }
func (s *FFmpegSource) Metadata() (string, string, string) {
return s.title, "Live Stream", ""
}
func (s *FFmpegSource) Close() error {
if s.stdout != nil {
s.stdout.Close()
}
if s.cmd != nil && s.cmd.Process != nil {
s.cmd.Process.Kill()
s.cmd.Wait()
}
return nil
}
// ResampledSource wraps an AudioSource and resamples to a target sample rate
type ResampledSource struct {
source AudioSource
resampler *Resampler
targetRate int
inputBuffer []int32
outputBuffer []int32
}
func NewResampledSource(source AudioSource, targetRate int) *ResampledSource {
inputRate := source.SampleRate()
channels := source.Channels()
inputSamples := (inputRate * channels * 100) / 1000
outputSamples := (targetRate * channels * 100) / 1000
return &ResampledSource{
source: source,
resampler: NewResampler(inputRate, targetRate, channels),
targetRate: targetRate,
inputBuffer: make([]int32, inputSamples),
outputBuffer: make([]int32, outputSamples*2), // Extra space for safety
}
}
func (r *ResampledSource) Read(samples []int32) (int, error) {
neededInput := r.resampler.InputSamplesNeeded(len(samples))
if neededInput > len(r.inputBuffer) {
neededInput = len(r.inputBuffer)
}
n, err := r.source.Read(r.inputBuffer[:neededInput])
if err != nil && err != io.EOF {
return 0, err
}
outputSamples := r.resampler.Resample(r.inputBuffer[:n], samples)
return outputSamples, nil
}
func (r *ResampledSource) SampleRate() int {
return r.targetRate
}
func (r *ResampledSource) Channels() int {
return r.source.Channels()
}
func (r *ResampledSource) Metadata() (string, string, string) {
return r.source.Metadata()
}
func (r *ResampledSource) Close() error {
return r.source.Close()
}

View File

@@ -0,0 +1,147 @@
// ABOUTME: FLAC audio encoder for lossless streaming
// ABOUTME: Wraps mewkiz/flac to encode PCM int32 samples to FLAC frames
package server
import (
"bytes"
"fmt"
"github.com/mewkiz/flac"
"github.com/mewkiz/flac/frame"
"github.com/mewkiz/flac/meta"
)
// FLACEncoder wraps a mewkiz/flac encoder for real-time FLAC frame
// generation. Each Encode call produces one FLAC frame from a block
// of interleaved int32 PCM samples. Prediction analysis is enabled
// so the encoder picks optimal Fixed/LPC prediction per block for
// real compression (not just verbatim framing).
type FLACEncoder struct {
encoder *flac.Encoder
buf *bytes.Buffer
codecHeader []byte
sampleRate int
channels int
bitDepth int
blockSize int
}
// NewFLACEncoder creates a FLAC encoder with prediction analysis enabled.
// blockSize is samples per channel per frame (e.g., 960 for 20ms at 48kHz).
func NewFLACEncoder(sampleRate, channels, bitDepth, blockSize int) (*FLACEncoder, error) {
buf := &bytes.Buffer{}
info := &meta.StreamInfo{
BlockSizeMin: uint16(blockSize),
BlockSizeMax: uint16(blockSize),
SampleRate: uint32(sampleRate),
NChannels: uint8(channels),
BitsPerSample: uint8(bitDepth),
}
enc, err := flac.NewEncoder(buf, info)
if err != nil {
return nil, fmt.Errorf("failed to create FLAC encoder: %w", err)
}
// Prediction analysis is enabled by default in mewkiz/flac v1.0.13.
// Explicit call for clarity: WriteFrame will analyze subframes marked
// PredVerbatim and pick the optimal prediction method (Constant/Fixed).
enc.EnablePredictionAnalysis(true)
// The encoder writes fLaC + STREAMINFO to the buffer immediately.
codecHeader := make([]byte, buf.Len())
copy(codecHeader, buf.Bytes())
buf.Reset()
return &FLACEncoder{
encoder: enc,
buf: buf,
codecHeader: codecHeader,
sampleRate: sampleRate,
channels: channels,
bitDepth: bitDepth,
blockSize: blockSize,
}, nil
}
// CodecHeader returns the FLAC codec header (fLaC + STREAMINFO metadata
// block). Base64-encode this for stream/start messages.
func (e *FLACEncoder) CodecHeader() []byte {
return e.codecHeader
}
// Encode converts a block of interleaved int32 PCM samples into a FLAC
// frame. len(samples) must equal blockSize * channels.
func (e *FLACEncoder) Encode(samples []int32) ([]byte, error) {
expected := e.blockSize * e.channels
if len(samples) != expected {
return nil, fmt.Errorf("expected %d samples (%d x %d), got %d",
expected, e.blockSize, e.channels, len(samples))
}
// Sendspin's AudioSource contract delivers int32 samples right-justified
// to 24 bits (see encodePCM / convertToInt16 in audio_engine.go). When
// the encoder's configured bit depth differs (e.g., 16-bit FLAC for an
// ESP32 that doesn't advertise 24-bit), down-shift so values fit the
// target range — otherwise the encoder emits frames with out-of-range
// samples that decoders reject, the client wedges, and TCP back-pressure
// stalls the server's WebSocket writer.
shift := uint(0)
if e.bitDepth < 24 {
shift = uint(24 - e.bitDepth)
}
// De-interleave into per-channel slices.
subframes := make([]*frame.Subframe, e.channels)
for ch := 0; ch < e.channels; ch++ {
channelSamples := make([]int32, e.blockSize)
for i := 0; i < e.blockSize; i++ {
channelSamples[i] = samples[i*e.channels+ch] >> shift
}
subframes[ch] = &frame.Subframe{
SubHeader: frame.SubHeader{
// PredVerbatim signals the encoder to run prediction analysis
// and pick the optimal method (Constant, Fixed, or Verbatim).
Pred: frame.PredVerbatim,
},
Samples: channelSamples,
NSamples: e.blockSize,
}
}
var channelAssign frame.Channels
switch e.channels {
case 1:
channelAssign = frame.ChannelsMono
case 2:
channelAssign = frame.ChannelsLR
default:
channelAssign = frame.Channels(e.channels)
}
f := &frame.Frame{
Header: frame.Header{
HasFixedBlockSize: true,
BlockSize: uint16(e.blockSize),
SampleRate: uint32(e.sampleRate),
Channels: channelAssign,
BitsPerSample: uint8(e.bitDepth),
},
Subframes: subframes,
}
e.buf.Reset()
if err := e.encoder.WriteFrame(f); err != nil {
return nil, fmt.Errorf("FLAC encode failed: %w", err)
}
result := make([]byte, e.buf.Len())
copy(result, e.buf.Bytes())
return result, nil
}
// Close finalizes the encoder.
func (e *FLACEncoder) Close() error {
return e.encoder.Close()
}

View File

@@ -0,0 +1,123 @@
// ABOUTME: Tests for the FLAC encoder
// ABOUTME: Verifies round-trip encode produces valid FLAC frames with compression
package server
import (
"testing"
)
func TestFLACEncoder_RoundTrip(t *testing.T) {
const (
sampleRate = 48000
channels = 2
bitDepth = 24
blockSize = 960 // 20ms at 48kHz
)
enc, err := NewFLACEncoder(sampleRate, channels, bitDepth, blockSize)
if err != nil {
t.Fatalf("NewFLACEncoder: %v", err)
}
defer enc.Close()
header := enc.CodecHeader()
if len(header) < 42 {
t.Fatalf("codec header too short: %d bytes (min 42)", len(header))
}
if string(header[:4]) != "fLaC" {
t.Errorf("codec header missing fLaC marker, got %q", header[:4])
}
// Create a simple test signal
samples := make([]int32, blockSize*channels)
for i := range samples {
samples[i] = int32((i % 256) << 16)
}
flacBytes, err := enc.Encode(samples)
if err != nil {
t.Fatalf("Encode: %v", err)
}
if len(flacBytes) == 0 {
t.Fatal("Encode returned empty bytes")
}
pcmSize := len(samples) * 3 // 24-bit = 3 bytes per sample
t.Logf("Encoded %d samples → %d FLAC bytes (%.1f%% of PCM %d bytes)",
len(samples), len(flacBytes), float64(len(flacBytes))/float64(pcmSize)*100, pcmSize)
}
func TestFLACEncoder_MultipleBlocks(t *testing.T) {
enc, err := NewFLACEncoder(48000, 2, 24, 960)
if err != nil {
t.Fatalf("NewFLACEncoder: %v", err)
}
defer enc.Close()
samples := make([]int32, 960*2)
for i := 0; i < 10; i++ {
data, err := enc.Encode(samples)
if err != nil {
t.Fatalf("Encode block %d: %v", i, err)
}
if len(data) == 0 {
t.Fatalf("Encode block %d returned empty", i)
}
}
}
func TestFLACEncoder_16Bit(t *testing.T) {
enc, err := NewFLACEncoder(44100, 2, 16, 882)
if err != nil {
t.Fatalf("NewFLACEncoder: %v", err)
}
defer enc.Close()
header := enc.CodecHeader()
if string(header[:4]) != "fLaC" {
t.Errorf("missing fLaC marker")
}
samples := make([]int32, 882*2)
data, err := enc.Encode(samples)
if err != nil {
t.Fatalf("Encode: %v", err)
}
if len(data) == 0 {
t.Fatal("empty encode")
}
}
func TestFLACEncoder_Compression(t *testing.T) {
// Silence should compress extremely well with prediction analysis enabled
enc, err := NewFLACEncoder(48000, 2, 24, 960)
if err != nil {
t.Fatalf("NewFLACEncoder: %v", err)
}
defer enc.Close()
silence := make([]int32, 960*2) // all zeros
data, err := enc.Encode(silence)
if err != nil {
t.Fatalf("Encode: %v", err)
}
pcmSize := 960 * 2 * 3 // 24-bit stereo
ratio := float64(len(data)) / float64(pcmSize) * 100
t.Logf("Silence: %d PCM bytes → %d FLAC bytes (%.1f%%)", pcmSize, len(data), ratio)
// Silence should compress to well under 10% of PCM size
if ratio > 20 {
t.Errorf("silence compression ratio %.1f%% is too high — prediction analysis may not be working", ratio)
}
}
func TestFLACEncoder_Close(t *testing.T) {
enc, err := NewFLACEncoder(48000, 2, 24, 960)
if err != nil {
t.Fatalf("NewFLACEncoder: %v", err)
}
if err := enc.Close(); err != nil {
t.Errorf("Close: %v", err)
}
}

View File

@@ -0,0 +1,60 @@
// ABOUTME: Opus audio encoder for bandwidth-efficient streaming
// ABOUTME: Wraps libopus to encode PCM audio to Opus format
package server
import (
"fmt"
"log"
"gopkg.in/hraban/opus.v2"
)
// OpusEncoder wraps the Opus encoder
type OpusEncoder struct {
encoder *opus.Encoder
sampleRate int
channels int
frameSize int // samples per channel per frame
}
// NewOpusEncoder creates a new Opus encoder.
// frameSize is in samples per channel (e.g., 960 for 20ms at 48kHz).
func NewOpusEncoder(sampleRate, channels, frameSize int) (*OpusEncoder, error) {
// AppAudio mode tunes the encoder for full-bandwidth music rather than speech
encoder, err := opus.NewEncoder(sampleRate, channels, opus.AppAudio)
if err != nil {
return nil, fmt.Errorf("failed to create opus encoder: %w", err)
}
// 128 kbps per channel — aggressive enough for transparent music quality
bitrate := 128000 * channels
if err := encoder.SetBitrate(bitrate); err != nil {
log.Printf("Warning: Failed to set Opus bitrate: %v", err)
}
return &OpusEncoder{
encoder: encoder,
sampleRate: sampleRate,
channels: channels,
frameSize: frameSize,
}, nil
}
// Encode encodes PCM samples to Opus.
// Input: []int16 interleaved samples; output: single Opus packet.
func (e *OpusEncoder) Encode(pcm []int16) ([]byte, error) {
// Opus spec maximum packet size is 4000 bytes
output := make([]byte, 4000)
n, err := e.encoder.Encode(pcm, output)
if err != nil {
return nil, fmt.Errorf("opus encode failed: %w", err)
}
return output[:n], nil
}
// Close is a no-op; opus.Encoder has no Close method.
func (e *OpusEncoder) Close() error {
return nil
}

View File

@@ -0,0 +1,261 @@
// ABOUTME: Tests for Opus audio encoder
// ABOUTME: Tests encoder creation, encoding, and format handling
package server
import (
"testing"
)
func TestNewOpusEncoder(t *testing.T) {
frameSize := 480 // 10ms at 48kHz
encoder, err := NewOpusEncoder(48000, 2, frameSize)
if err != nil {
t.Fatalf("failed to create encoder: %v", err)
}
if encoder == nil {
t.Fatal("expected encoder to be created")
}
if encoder.sampleRate != 48000 {
t.Errorf("expected sampleRate 48000, got %d", encoder.sampleRate)
}
if encoder.channels != 2 {
t.Errorf("expected channels 2, got %d", encoder.channels)
}
}
func TestOpusEncoderInvalidSampleRate(t *testing.T) {
// Opus only supports 8, 12, 16, 24, 48 kHz
_, err := NewOpusEncoder(44100, 2, 480)
if err == nil {
t.Fatal("expected error for invalid sample rate 44100")
}
}
func TestOpusEncoderInvalidChannels(t *testing.T) {
// Opus supports 1-2 channels
_, err := NewOpusEncoder(48000, 5, 480)
if err == nil {
t.Fatal("expected error for invalid channels 5")
}
}
func TestOpusEncodeValidFrame(t *testing.T) {
encoder, err := NewOpusEncoder(48000, 2, 480)
if err != nil {
t.Fatalf("failed to create encoder: %v", err)
}
// Opus at 48kHz expects frames of 2.5, 5, 10, 20, 40, or 60ms
// 10ms at 48kHz stereo = 480 samples * 2 channels = 960 int16 values
pcm := make([]int16, 960)
for i := range pcm {
pcm[i] = int16(i * 10) // Simple ramp signal
}
encoded, err := encoder.Encode(pcm)
if err != nil {
t.Fatalf("encode failed: %v", err)
}
if len(encoded) == 0 {
t.Fatal("expected non-empty encoded output")
}
// Opus encoding should compress the data
// Encoded size should be much smaller than PCM (960 samples * 2 bytes = 1920 bytes)
if len(encoded) >= len(pcm)*2 {
t.Errorf("expected compression, but encoded size %d >= PCM size %d", len(encoded), len(pcm)*2)
}
}
func TestOpusEncodeSilence(t *testing.T) {
encoder, err := NewOpusEncoder(48000, 2, 480)
if err != nil {
t.Fatalf("failed to create encoder: %v", err)
}
// All zeros (silence)
pcm := make([]int16, 960)
encoded, err := encoder.Encode(pcm)
if err != nil {
t.Fatalf("encode failed: %v", err)
}
if len(encoded) == 0 {
t.Fatal("expected non-empty encoded output even for silence")
}
// Silence should compress very well
if len(encoded) > 50 {
t.Logf("silence encoded to %d bytes (expected very small)", len(encoded))
}
}
func TestOpusEncodeFullScale(t *testing.T) {
encoder, err := NewOpusEncoder(48000, 2, 480)
if err != nil {
t.Fatalf("failed to create encoder: %v", err)
}
// Full scale signal (maximum amplitude)
pcm := make([]int16, 960)
for i := range pcm {
if i%2 == 0 {
pcm[i] = 32767 // Max positive
} else {
pcm[i] = -32768 // Max negative
}
}
encoded, err := encoder.Encode(pcm)
if err != nil {
t.Fatalf("encode failed: %v", err)
}
if len(encoded) == 0 {
t.Fatal("expected non-empty encoded output")
}
}
func TestOpusEncodeMultipleFrames(t *testing.T) {
encoder, err := NewOpusEncoder(48000, 2, 480)
if err != nil {
t.Fatalf("failed to create encoder: %v", err)
}
// Encode multiple frames in sequence
for frame := 0; frame < 10; frame++ {
pcm := make([]int16, 960)
for i := range pcm {
pcm[i] = int16((frame * 1000) + i)
}
encoded, err := encoder.Encode(pcm)
if err != nil {
t.Fatalf("encode frame %d failed: %v", frame, err)
}
if len(encoded) == 0 {
t.Fatalf("frame %d produced empty output", frame)
}
}
}
func TestOpusEncodeMono(t *testing.T) {
encoder, err := NewOpusEncoder(48000, 1, 480)
if err != nil {
t.Fatalf("failed to create mono encoder: %v", err)
}
// Mono: 480 samples for 10ms at 48kHz
pcm := make([]int16, 480)
for i := range pcm {
pcm[i] = int16(i * 20)
}
encoded, err := encoder.Encode(pcm)
if err != nil {
t.Fatalf("mono encode failed: %v", err)
}
if len(encoded) == 0 {
t.Fatal("expected non-empty encoded output for mono")
}
}
func TestOpusEncodeInvalidFrameSize(t *testing.T) {
encoder, err := NewOpusEncoder(48000, 2, 480)
if err != nil {
t.Fatalf("failed to create encoder: %v", err)
}
// Wrong frame size (not 2.5/5/10/20/40/60ms worth of samples)
pcm := make([]int16, 100) // Way too small
_, err = encoder.Encode(pcm)
if err == nil {
t.Log("Note: encoder may accept invalid frame sizes (implementation dependent)")
}
}
func TestOpusEncodeDifferentFrameSizes(t *testing.T) {
encoder, err := NewOpusEncoder(48000, 2, 480)
if err != nil {
t.Fatalf("failed to create encoder: %v", err)
}
// Test different valid frame sizes at 48kHz stereo
frameSizes := []int{
240, // 2.5ms: 120 samples * 2 channels
480, // 5ms: 240 samples * 2 channels
960, // 10ms: 480 samples * 2 channels
1920, // 20ms: 960 samples * 2 channels
}
for _, size := range frameSizes {
pcm := make([]int16, size)
for i := range pcm {
pcm[i] = int16(i * 5)
}
encoded, err := encoder.Encode(pcm)
if err != nil {
t.Logf("frame size %d failed (may not be supported): %v", size, err)
continue
}
if len(encoded) == 0 {
t.Errorf("frame size %d produced empty output", size)
}
}
}
func TestOpusEncode24kHz(t *testing.T) {
// Test 24kHz (valid Opus sample rate)
encoder, err := NewOpusEncoder(24000, 2, 240)
if err != nil {
t.Fatalf("failed to create 24kHz encoder: %v", err)
}
// 10ms at 24kHz stereo = 240 samples * 2 channels = 480 values
pcm := make([]int16, 480)
for i := range pcm {
pcm[i] = int16(i * 10)
}
encoded, err := encoder.Encode(pcm)
if err != nil {
t.Fatalf("24kHz encode failed: %v", err)
}
if len(encoded) == 0 {
t.Fatal("expected non-empty encoded output at 24kHz")
}
}
func TestOpusEncode16kHz(t *testing.T) {
// Test 16kHz (valid Opus sample rate, narrowband)
encoder, err := NewOpusEncoder(16000, 1, 160)
if err != nil {
t.Fatalf("failed to create 16kHz encoder: %v", err)
}
// 10ms at 16kHz mono = 160 samples
pcm := make([]int16, 160)
for i := range pcm {
pcm[i] = int16(i * 10)
}
encoded, err := encoder.Encode(pcm)
if err != nil {
t.Fatalf("16kHz encode failed: %v", err)
}
if len(encoded) == 0 {
t.Fatal("expected non-empty encoded output at 16kHz")
}
}

View File

@@ -0,0 +1,84 @@
// ABOUTME: Simple linear resampler for converting audio sample rates
// ABOUTME: Used to convert MP3 files to 48kHz for Opus encoding
package server
// Resampler performs linear interpolation to convert between sample rates
type Resampler struct {
inputRate int
outputRate int
channels int
ratio float64
position float64
lastSample []int32 // one sample per channel
}
func NewResampler(inputRate, outputRate, channels int) *Resampler {
return &Resampler{
inputRate: inputRate,
outputRate: outputRate,
channels: channels,
ratio: float64(inputRate) / float64(outputRate),
position: 0.0,
lastSample: make([]int32, channels),
}
}
// Resample converts interleaved input samples at inputRate to outputRate via linear interpolation.
func (r *Resampler) Resample(input []int32, output []int32) int {
if len(input) == 0 {
return 0
}
inputFrames := len(input) / r.channels
outputFrames := len(output) / r.channels
outIdx := 0
for outIdx < outputFrames {
// Calculate which input frame we need
inputPos := r.position
inputIdx := int(inputPos)
// If we've consumed all input, stop
if inputIdx >= inputFrames-1 {
break
}
frac := inputPos - float64(inputIdx)
for ch := 0; ch < r.channels; ch++ {
sample1 := input[inputIdx*r.channels+ch]
sample2 := input[(inputIdx+1)*r.channels+ch]
interpolated := float64(sample1)*(1.0-frac) + float64(sample2)*frac
output[outIdx*r.channels+ch] = int32(interpolated)
}
outIdx++
r.position += r.ratio
}
// Reset position for next chunk, keeping fractional part
r.position -= float64(int(r.position))
return outIdx * r.channels
}
func (r *Resampler) Reset() {
r.position = 0.0
for i := range r.lastSample {
r.lastSample[i] = 0
}
}
func (r *Resampler) OutputSamplesNeeded(inputSamples int) int {
inputFrames := inputSamples / r.channels
outputFrames := int(float64(inputFrames) / r.ratio)
return outputFrames * r.channels
}
func (r *Resampler) InputSamplesNeeded(outputSamples int) int {
outputFrames := outputSamples / r.channels
inputFrames := int(float64(outputFrames) * r.ratio)
return inputFrames * r.channels
}

View File

@@ -0,0 +1,260 @@
// ABOUTME: Tests for audio resampler
// ABOUTME: Tests linear interpolation resampling between sample rates
package server
import (
"testing"
)
func TestNewResampler(t *testing.T) {
r := NewResampler(44100, 48000, 2)
if r == nil {
t.Fatal("expected resampler to be created")
}
if r.inputRate != 44100 {
t.Errorf("expected inputRate 44100, got %d", r.inputRate)
}
if r.outputRate != 48000 {
t.Errorf("expected outputRate 48000, got %d", r.outputRate)
}
if r.channels != 2 {
t.Errorf("expected channels 2, got %d", r.channels)
}
}
func TestResampleUpsampling(t *testing.T) {
// 44100 -> 48000 (upsampling by factor of ~1.088)
r := NewResampler(44100, 48000, 2)
// Input: 100 stereo samples (200 int16 values)
input := make([]int32, 200)
for i := range input {
input[i] = int32(i * 100) // Ramp signal
}
// Calculate expected output size
expectedSize := int(float64(len(input)) * float64(48000) / float64(44100))
output := make([]int32, expectedSize)
n := r.Resample(input, output)
// Should have produced output
if n == 0 {
t.Fatal("resampler produced no output")
}
// Should have produced approximately the expected amount
// Allow some tolerance due to rounding
if n < expectedSize-10 || n > expectedSize+10 {
t.Errorf("expected ~%d samples, got %d", expectedSize, n)
}
// Output should have interpolated values (not exact copies)
allZero := true
for i := 0; i < n; i++ {
if output[i] != 0 {
allZero = false
break
}
}
if allZero {
t.Error("output contains only zeros")
}
}
func TestResampleDownsampling(t *testing.T) {
// 48000 -> 44100 (downsampling by factor of ~0.91875)
r := NewResampler(48000, 44100, 2)
// Input: 100 stereo samples
input := make([]int32, 200)
for i := range input {
input[i] = int32(i * 100)
}
expectedSize := int(float64(len(input)) * float64(44100) / float64(48000))
output := make([]int32, expectedSize)
n := r.Resample(input, output)
if n == 0 {
t.Fatal("resampler produced no output")
}
if n < expectedSize-10 || n > expectedSize+10 {
t.Errorf("expected ~%d samples, got %d", expectedSize, n)
}
}
func TestResampleSameRate(t *testing.T) {
// No resampling needed (48000 -> 48000)
r := NewResampler(48000, 48000, 2)
input := make([]int32, 200)
for i := range input {
input[i] = int32(i * 100)
}
output := make([]int32, len(input)+10) // Extra space for rounding
n := r.Resample(input, output)
// Should produce approximately the same number of samples
// Allow small tolerance for floating point rounding
if n < len(input)-5 || n > len(input)+5 {
t.Errorf("expected ~%d samples, got %d", len(input), n)
}
// Values should be similar (allow for interpolation artifacts)
for i := 0; i < n && i < len(input); i++ {
diff := abs(int(output[i]) - int(input[i]))
if diff > 200 { // Allow some rounding errors
t.Errorf("sample %d: expected ~%d, got %d (diff %d)", i, input[i], output[i], diff)
}
}
}
func TestResampleStereo(t *testing.T) {
// Test that stereo channels are handled correctly
r := NewResampler(44100, 48000, 2)
// Create input with different L/R patterns
input := make([]int32, 20) // 10 stereo samples
for i := 0; i < 10; i++ {
input[i*2] = 1000 // Left channel
input[i*2+1] = -1000 // Right channel
}
output := make([]int32, 30) // Space for upsampled output
n := r.Resample(input, output)
if n == 0 {
t.Fatal("resampler produced no output")
}
// Check that L/R pattern is preserved (approximately)
leftPositive := 0
rightNegative := 0
for i := 0; i < n/2; i++ {
if output[i*2] > 0 {
leftPositive++
}
if output[i*2+1] < 0 {
rightNegative++
}
}
// Most samples should maintain the pattern
if leftPositive < n/4 {
t.Error("left channel pattern not preserved")
}
if rightNegative < n/4 {
t.Error("right channel pattern not preserved")
}
}
func TestResampleMono(t *testing.T) {
// Test mono resampling
r := NewResampler(44100, 48000, 1)
input := make([]int32, 100)
for i := range input {
input[i] = int32(i * 50)
}
expectedSize := int(float64(len(input)) * float64(48000) / float64(44100))
output := make([]int32, expectedSize)
n := r.Resample(input, output)
if n == 0 {
t.Fatal("resampler produced no output")
}
}
func TestResampleLargeRatioUp(t *testing.T) {
// Test large upsampling ratio (44.1k -> 192k)
r := NewResampler(44100, 192000, 2)
input := make([]int32, 200)
for i := range input {
input[i] = int32(i * 10)
}
expectedSize := int(float64(len(input)) * float64(192000) / float64(44100))
output := make([]int32, expectedSize)
n := r.Resample(input, output)
if n == 0 {
t.Fatal("resampler produced no output")
}
// Should have significantly more samples
if n < len(input)*3 {
t.Errorf("expected at least 3x upsampling, got %d from %d", n, len(input))
}
}
func TestResampleLargeRatioDown(t *testing.T) {
// Test large downsampling ratio (192k -> 48k)
r := NewResampler(192000, 48000, 2)
input := make([]int32, 200)
for i := range input {
input[i] = int32(i * 10)
}
expectedSize := int(float64(len(input)) * float64(48000) / float64(192000))
output := make([]int32, expectedSize)
n := r.Resample(input, output)
if n == 0 {
t.Fatal("resampler produced no output")
}
// Should have significantly fewer samples
if n > len(input)/2 {
t.Errorf("expected at most 1/2 samples after downsampling, got %d from %d", n, len(input))
}
}
func TestResampleEmptyInput(t *testing.T) {
r := NewResampler(44100, 48000, 2)
input := []int32{}
output := make([]int32, 100)
n := r.Resample(input, output)
if n != 0 {
t.Errorf("expected 0 samples from empty input, got %d", n)
}
}
func TestResampleSmallBuffer(t *testing.T) {
r := NewResampler(44100, 48000, 2)
// Small input
input := []int32{100, -100, 200, -200}
output := make([]int32, 10)
n := r.Resample(input, output)
// Should produce some output
if n == 0 {
t.Fatal("resampler produced no output from small buffer")
}
}
// Helper function
func abs(x int) int {
if x < 0 {
return -x
}
return x
}

View File

@@ -0,0 +1,605 @@
// ABOUTME: Main server implementation for Sendspin Protocol
// ABOUTME: Manages WebSocket connections, client state, and audio streaming
package server
import (
"context"
"encoding/binary"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"sync"
"time"
"github.com/Sendspin/sendspin-go/internal/discovery"
"github.com/Sendspin/sendspin-go/pkg/protocol"
"github.com/google/uuid"
"github.com/gorilla/websocket"
)
const (
// Protocol constants
ProtocolVersion = 1
// Message type for binary audio chunks
// Per spec: Player role binary messages use IDs 4-7 (bits 000001xx), slot 0 is audio
AudioChunkMessageType = 4
)
// Config holds server configuration
type Config struct {
Port int
Name string
EnableMDNS bool
Debug bool
UseTUI bool
AudioFile string // Path to audio file to stream (MP3, FLAC, WAV). Empty = test tone
}
// Server represents the Sendspin server
type Server struct {
config Config
serverID string
// WebSocket upgrader
upgrader websocket.Upgrader
// HTTP server
httpServer *http.Server
mux *http.ServeMux
// Client management
clients map[string]*Client
clientsMu sync.RWMutex
// Server clock (monotonic microseconds)
clockStart time.Time
// Audio streaming
audioEngine *AudioEngine
// mDNS discovery
mdnsManager *discovery.Manager
// TUI
tui *ServerTUI
startTime time.Time
// Control
stopChan chan struct{}
stopOnce sync.Once // Ensure Stop() is only called once
shutdownMu sync.RWMutex
isShutdown bool
wg sync.WaitGroup
}
// Client represents a connected client
type Client struct {
ID string
Name string
Conn *websocket.Conn
Roles []string
Capabilities *protocol.PlayerV1Support
// State
State string
Volume int
Muted bool
// Negotiated codec for this client
Codec string // "pcm" or "opus" (flac falls back to pcm)
OpusEncoder *OpusEncoder // Opus encoder (if using opus codec)
Resampler *Resampler // Resampler for Opus (if source rate != 48kHz)
// Output channel for messages
sendChan chan interface{}
done chan struct{}
mu sync.RWMutex
}
func New(config Config) *Server {
mux := http.NewServeMux()
return &Server{
config: config,
serverID: uuid.New().String(),
mux: mux,
upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
// TODO: For production deployment, implement proper origin validation
// Currently allows all origins for local network deployments
// This server is designed for trusted local networks only
origin := r.Header.Get("Origin")
if origin == "" {
// Allow non-browser clients (no Origin header)
return true
}
// Accept localhost origins for development
if origin == "http://localhost" || origin == "http://127.0.0.1" {
return true
}
// For production: implement allowlist-based validation
log.Printf("Warning: accepting WebSocket from origin: %s", origin)
return true
},
},
clients: make(map[string]*Client),
clockStart: time.Now(),
startTime: time.Now(),
stopChan: make(chan struct{}),
}
}
func (s *Server) Start() error {
if s.config.UseTUI {
s.tui = NewServerTUI(s.config.Name, s.config.Port)
s.wg.Add(1)
go func() {
defer s.wg.Done()
s.tui.Start(s.config.Name, s.config.Port)
}()
// Give TUI time to initialize
time.Sleep(100 * time.Millisecond)
}
log.Printf("Server starting: %s (ID: %s)", s.config.Name, s.serverID)
audioEngine, err := NewAudioEngine(s)
if err != nil {
return fmt.Errorf("failed to create audio engine: %w", err)
}
s.audioEngine = audioEngine
if s.config.EnableMDNS {
s.mdnsManager = discovery.NewManager(discovery.Config{
ServiceName: s.config.Name,
Port: s.config.Port,
ServerMode: true, // Advertise as server
})
if err := s.mdnsManager.Advertise(); err != nil {
log.Printf("Failed to start mDNS advertisement: %v", err)
} else {
log.Printf("mDNS advertisement started")
}
}
s.mux.HandleFunc("/sendspin", s.handleWebSocket)
s.wg.Add(1)
go func() {
defer s.wg.Done()
s.audioEngine.Start()
}()
addr := fmt.Sprintf(":%d", s.config.Port)
log.Printf("WebSocket server listening on %s", addr)
s.httpServer = &http.Server{
Addr: addr,
Handler: s.mux,
}
errChan := make(chan error, 1)
go func() {
if err := s.httpServer.ListenAndServe(); err != http.ErrServerClosed {
errChan <- err
}
}()
var serverErr error
var tuiQuitChan <-chan struct{}
if s.tui != nil {
tuiQuitChan = s.tui.QuitChan()
}
select {
case <-s.stopChan:
log.Printf("Server shutting down...")
case <-tuiQuitChan:
log.Printf("TUI quit requested, shutting down...")
case err := <-errChan:
log.Printf("HTTP server error: %v", err)
serverErr = err
// Fall through to cleanup
}
s.shutdownMu.Lock()
s.isShutdown = true
s.shutdownMu.Unlock()
if s.tui != nil {
s.tui.Stop()
}
s.audioEngine.Stop()
if s.mdnsManager != nil {
s.mdnsManager.Stop()
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.httpServer.Shutdown(ctx); err != nil {
log.Printf("HTTP server shutdown error: %v", err)
}
s.wg.Wait()
log.Printf("Server stopped cleanly")
if serverErr != nil {
return fmt.Errorf("HTTP server failed: %w", serverErr)
}
return nil
}
func (s *Server) Stop() {
s.stopOnce.Do(func() {
close(s.stopChan)
})
}
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
conn, err := s.upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("WebSocket upgrade error: %v", err)
return
}
log.Printf("New WebSocket connection from %s", r.RemoteAddr)
s.handleConnection(conn)
}
func (s *Server) handleConnection(conn *websocket.Conn) {
defer conn.Close()
conn.SetReadLimit(1 << 20) // 1MB
s.shutdownMu.RLock()
if s.isShutdown {
s.shutdownMu.RUnlock()
log.Printf("Rejecting connection during shutdown")
return
}
s.shutdownMu.RUnlock()
if s.config.Debug {
log.Printf("[DEBUG] New connection, waiting for handshake")
}
_, data, err := conn.ReadMessage()
if err != nil {
log.Printf("Error reading hello: %v", err)
return
}
var msg protocol.Message
if err := json.Unmarshal(data, &msg); err != nil {
log.Printf("Error unmarshaling message: %v", err)
return
}
if msg.Type != "client/hello" {
log.Printf("Expected client/hello, got %s", msg.Type)
return
}
helloData, err := json.Marshal(msg.Payload)
if err != nil {
log.Printf("Error marshaling hello payload: %v", err)
return
}
var hello protocol.ClientHello
if err := json.Unmarshal(helloData, &hello); err != nil {
log.Printf("Error unmarshaling client hello: %v", err)
return
}
if hello.ClientID == "" {
log.Printf("Client hello missing ClientID")
return
}
if hello.Name == "" {
log.Printf("Client hello missing Name")
return
}
if len(hello.ClientID) > 256 || len(hello.Name) > 256 || len(hello.SupportedRoles) > 20 {
log.Printf("Client hello fields exceed size limits")
return
}
log.Printf("Client hello: %s (ID: %s, Roles: %v)", hello.Name, hello.ClientID, hello.SupportedRoles)
client := &Client{
ID: hello.ClientID,
Name: hello.Name,
Conn: conn,
Roles: hello.SupportedRoles,
Capabilities: hello.PlayerV1Support,
State: "idle",
Volume: 100,
Muted: false,
sendChan: make(chan interface{}, 100),
done: make(chan struct{}),
}
s.clientsMu.Lock()
if existingClient, exists := s.clients[hello.ClientID]; exists {
s.clientsMu.Unlock()
log.Printf("Client ID %s already connected (name: %s), rejecting duplicate", hello.ClientID, existingClient.Name)
// Send error message to client
errorMsg := protocol.Message{
Type: "server/error",
Payload: map[string]string{
"error": "duplicate_client_id",
"message": "Client ID already connected",
},
}
if data, err := json.Marshal(errorMsg); err == nil {
conn.WriteMessage(websocket.TextMessage, data)
}
return
}
s.clients[client.ID] = client
s.clientsMu.Unlock()
s.updateTUI()
defer func() {
s.clientsMu.Lock()
delete(s.clients, client.ID)
s.clientsMu.Unlock()
close(client.done)
log.Printf("Client disconnected: %s", client.Name)
s.updateTUI()
}()
serverHello := protocol.ServerHello{
ServerID: s.serverID,
Name: s.config.Name,
Version: ProtocolVersion,
ActiveRoles: s.activateRoles(hello.SupportedRoles),
ConnectionReason: "playback",
}
if err := s.sendMessage(client, "server/hello", serverHello); err != nil {
log.Printf("Error sending server hello: %v", err)
return
}
s.wg.Add(1)
go func() {
defer s.wg.Done()
s.clientWriter(client)
}()
if s.hasRole(client, "player") {
s.audioEngine.AddClient(client)
defer s.audioEngine.RemoveClient(client)
}
for {
_, data, err := conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("WebSocket error: %v", err)
}
break
}
s.handleClientMessage(client, data)
}
}
func (s *Server) clientWriter(client *Client) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
const writeDeadline = 10 * time.Second
for {
select {
case msg := <-client.sendChan:
switch v := msg.(type) {
case []byte:
client.Conn.SetWriteDeadline(time.Now().Add(writeDeadline))
if err := client.Conn.WriteMessage(websocket.BinaryMessage, v); err != nil {
log.Printf("Error writing binary message: %v", err)
return
}
default:
data, err := json.Marshal(v)
if err != nil {
log.Printf("Error marshaling message: %v", err)
continue
}
client.Conn.SetWriteDeadline(time.Now().Add(writeDeadline))
if err := client.Conn.WriteMessage(websocket.TextMessage, data); err != nil {
log.Printf("Error writing text message: %v", err)
return
}
}
case <-ticker.C:
if err := client.Conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(10*time.Second)); err != nil {
return
}
case <-client.done:
return
}
}
}
func (s *Server) handleClientMessage(client *Client, data []byte) {
var msg protocol.Message
if err := json.Unmarshal(data, &msg); err != nil {
log.Printf("Error unmarshaling message: %v", err)
return
}
switch msg.Type {
case "client/time":
s.handleTimeSync(client, msg.Payload)
case "player/update":
s.handleClientState(client, msg.Payload)
case "client/state":
s.handleClientState(client, msg.Payload)
default:
log.Printf("Unknown message type: %s", msg.Type)
}
}
func (s *Server) handleTimeSync(client *Client, payload interface{}) {
// Capture receive time as early as possible
serverRecv := s.getClockMicros()
timeData, err := json.Marshal(payload)
if err != nil {
log.Printf("Error marshaling time payload: %v", err)
return
}
var clientTime protocol.ClientTime
if err := json.Unmarshal(timeData, &clientTime); err != nil {
log.Printf("Error unmarshaling client time: %v", err)
return
}
// Note: This timestamp is the queue time, not the actual wire time.
// The message is queued to sendChan and transmitted asynchronously by clientWriter.
// For more accurate timing, the timestamp would need to be captured immediately
// before the actual WebSocket write operation.
serverSend := s.getClockMicros()
if s.config.Debug {
log.Printf("[DEBUG] Time sync for %s: t1=%d, t2=%d, t3=%d",
client.Name, clientTime.ClientTransmitted, serverRecv, serverSend)
}
response := protocol.ServerTime{
ClientTransmitted: clientTime.ClientTransmitted,
ServerReceived: serverRecv,
ServerTransmitted: serverSend,
}
if err := s.sendMessage(client, "server/time", response); err != nil {
log.Printf("Error sending server time: %v", err)
}
}
// handleClientState accepts both legacy "player/update" and spec-style "client/state" payloads.
func (s *Server) handleClientState(client *Client, payload interface{}) {
stateData, err := json.Marshal(payload)
if err != nil {
log.Printf("Error marshaling state payload: %v", err)
return
}
var wrapped struct {
Player *protocol.ClientState `json:"player,omitempty"`
}
if err := json.Unmarshal(stateData, &wrapped); err == nil && wrapped.Player != nil {
s.applyClientState(client, *wrapped.Player)
return
}
var state protocol.ClientState
if err := json.Unmarshal(stateData, &state); err == nil {
s.applyClientState(client, state)
return
}
log.Printf("Error unmarshaling client state: %s", string(stateData))
}
func (s *Server) applyClientState(client *Client, state protocol.ClientState) {
client.mu.Lock()
client.State = state.State
client.Volume = state.Volume
client.Muted = state.Muted
client.mu.Unlock()
log.Printf("Client %s state: %s (vol: %d, muted: %v)", client.Name, state.State, state.Volume, state.Muted)
}
func (s *Server) sendMessage(client *Client, msgType string, payload interface{}) error {
msg := protocol.Message{
Type: msgType,
Payload: payload,
}
select {
case client.sendChan <- msg:
return nil
default:
return fmt.Errorf("client send buffer full")
}
}
func (s *Server) sendBinary(client *Client, data []byte) error {
select {
case client.sendChan <- data:
return nil
default:
return fmt.Errorf("client send buffer full")
}
}
func (s *Server) getClockMicros() int64 {
return time.Since(s.clockStart).Microseconds()
}
// hasRole checks if a client has a role, accepting both bare ("player") and versioned ("player@1") forms.
func (s *Server) hasRole(client *Client, role string) bool {
for _, r := range client.Roles {
if r == role || strings.HasPrefix(r, role+"@") {
return true
}
}
return false
}
// activateRoles filters to roles this server implements, preserving input order.
func (s *Server) activateRoles(supportedRoles []string) []string {
seen := make(map[string]bool)
result := make([]string, 0, len(supportedRoles))
for _, role := range supportedRoles {
family := role
if idx := strings.Index(role, "@"); idx > 0 {
family = role[:idx]
}
if seen[family] {
continue
}
switch family {
case "player", "metadata":
seen[family] = true
result = append(result, role)
}
}
return result
}
// CreateAudioChunk encodes a binary audio frame: [type:1][timestamp:8][data:N].
func CreateAudioChunk(timestamp int64, audioData []byte) []byte {
chunk := make([]byte, 1+8+len(audioData))
chunk[0] = AudioChunkMessageType
binary.BigEndian.PutUint64(chunk[1:9], uint64(timestamp))
copy(chunk[9:], audioData)
return chunk
}

View File

@@ -0,0 +1,50 @@
// ABOUTME: Test tone generator for audio source
// ABOUTME: Generates 440Hz sine wave for testing
package server
import (
"math"
"sync"
)
type TestToneSource struct {
sampleIndex uint64
sampleMu sync.Mutex
frequency float64
}
func NewTestToneSource() *TestToneSource {
return &TestToneSource{
frequency: 440.0, // A4 note
}
}
func (s *TestToneSource) Read(samples []int32) (int, error) {
s.sampleMu.Lock()
defer s.sampleMu.Unlock()
numSamples := len(samples) / 2 // Stereo
for i := 0; i < numSamples; i++ {
t := float64(s.sampleIndex+uint64(i)) / float64(DefaultSampleRate)
sample := math.Sin(2 * math.Pi * s.frequency * t)
// 50% amplitude to avoid clipping on a full-scale sine
const max24bit = 8388607 // 2^23 - 1
pcmValue := int32(sample * max24bit * 0.5)
samples[i*2] = pcmValue
samples[i*2+1] = pcmValue
}
s.sampleIndex += uint64(numSamples)
return len(samples), nil
}
func (s *TestToneSource) SampleRate() int { return DefaultSampleRate }
func (s *TestToneSource) Channels() int { return DefaultChannels }
func (s *TestToneSource) Metadata() (string, string, string) {
return "Test Tone", "Sendspin Server", "Reference Implementation"
}
func (s *TestToneSource) Close() error { return nil }

View File

@@ -0,0 +1,204 @@
// ABOUTME: Server TUI for displaying connected clients and stats
// ABOUTME: Real-time server status display using bubbletea
package server
import (
"fmt"
"strings"
"sync"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type ServerTUI struct {
program *tea.Program
updates chan ServerStatus
quitChan chan struct{} // Signal to stop the server
stopped bool
mu sync.Mutex
}
type ServerStatus struct {
Name string
Port int
Uptime time.Duration
Clients []ClientInfo
AudioTitle string
}
type ClientInfo struct {
Name string
ID string
Codec string
State string
}
type tuiModel struct {
status ServerStatus
startTime time.Time
quitting bool
quitChan chan struct{} // Channel to signal server stop
}
type tickMsg time.Time
type statusMsg ServerStatus
func (m tuiModel) Init() tea.Cmd {
return tea.Batch(
tickEvery(),
)
}
func tickEvery() tea.Cmd {
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "q" || msg.String() == "ctrl+c" {
m.quitting = true
select {
case m.quitChan <- struct{}{}:
default:
}
return m, tea.Quit
}
case tickMsg:
return m, tickEvery()
case statusMsg:
m.status = ServerStatus(msg)
return m, nil
}
return m, nil
}
func (m tuiModel) View() string {
if m.quitting {
return "Shutting down server...\n"
}
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("205")).
MarginBottom(1)
headerStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("86"))
valueStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("250"))
clientHeaderStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("220"))
var b strings.Builder
b.WriteString(titleStyle.Render("Sendspin Server"))
b.WriteString("\n\n")
b.WriteString(headerStyle.Render("Server: "))
b.WriteString(valueStyle.Render(m.status.Name))
b.WriteString("\n")
b.WriteString(headerStyle.Render("Port: "))
b.WriteString(valueStyle.Render(fmt.Sprintf("%d", m.status.Port)))
b.WriteString("\n")
b.WriteString(headerStyle.Render("Uptime: "))
uptime := time.Since(m.startTime).Round(time.Second)
b.WriteString(valueStyle.Render(uptime.String()))
b.WriteString("\n")
b.WriteString(headerStyle.Render("Playing: "))
b.WriteString(valueStyle.Render(m.status.AudioTitle))
b.WriteString("\n\n")
b.WriteString(clientHeaderStyle.Render(fmt.Sprintf("Connected Clients (%d)", len(m.status.Clients))))
b.WriteString("\n\n")
if len(m.status.Clients) == 0 {
b.WriteString(valueStyle.Render(" No clients connected"))
b.WriteString("\n")
} else {
for _, client := range m.status.Clients {
b.WriteString(fmt.Sprintf(" • %s", client.Name))
b.WriteString(valueStyle.Render(fmt.Sprintf(" (%s, %s)", client.Codec, client.State)))
b.WriteString("\n")
}
}
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Faint(true).Render("Press 'q' or Ctrl+C to quit"))
return b.String()
}
func NewServerTUI(serverName string, port int) *ServerTUI {
return &ServerTUI{
updates: make(chan ServerStatus, 10),
quitChan: make(chan struct{}, 1),
}
}
func (t *ServerTUI) Start(serverName string, port int) error {
m := tuiModel{
status: ServerStatus{
Name: serverName,
Port: port,
AudioTitle: "Initializing...",
Clients: []ClientInfo{},
},
startTime: time.Now(),
quitChan: t.quitChan,
}
t.program = tea.NewProgram(m, tea.WithAltScreen())
go func() {
for status := range t.updates {
if t.program != nil {
t.program.Send(statusMsg(status))
}
}
}()
_, err := t.program.Run()
return err
}
func (t *ServerTUI) Update(status ServerStatus) {
t.mu.Lock()
defer t.mu.Unlock()
if t.stopped {
return
}
select {
case t.updates <- status:
default:
}
}
func (t *ServerTUI) Stop() {
t.mu.Lock()
t.stopped = true
t.mu.Unlock()
if t.program != nil {
t.program.Quit()
}
close(t.updates)
}
func (t *ServerTUI) QuitChan() <-chan struct{} {
return t.quitChan
}

View File

@@ -0,0 +1,44 @@
// ABOUTME: TUI update helpers for server
// ABOUTME: Functions to send server state updates to TUI
package server
func (s *Server) updateTUI() {
if s.tui == nil {
return
}
s.clientsMu.RLock()
defer s.clientsMu.RUnlock()
clients := make([]ClientInfo, 0, len(s.clients))
for _, client := range s.clients {
client.mu.RLock()
codec := client.Codec
state := client.State
client.mu.RUnlock()
clients = append(clients, ClientInfo{
Name: client.Name,
ID: client.ID,
Codec: codec,
State: state,
})
}
audioTitle := "Test Tone (440Hz)"
if s.audioEngine != nil && s.audioEngine.source != nil {
title, artist, _ := s.audioEngine.source.Metadata()
if artist != "" {
audioTitle = artist + " - " + title
} else {
audioTitle = title
}
}
s.tui.Update(ServerStatus{
Name: s.config.Name,
Port: s.config.Port,
Clients: clients,
AudioTitle: audioTitle,
})
}

View File

@@ -0,0 +1,208 @@
// ABOUTME: Modal picker for audio output devices — shown over the main view
// ABOUTME: Save-and-restart model: selection writes audio_device to player.yaml
package ui
import (
"fmt"
"strings"
"github.com/Sendspin/sendspin-go/pkg/audio/output"
)
// pickerAction is what handlePickerKey tells the Model to do next. The
// picker itself never writes to disk; the Model layer owns side effects so
// the pure state machine here stays trivially testable.
type pickerAction int
const (
pickerNoop pickerAction = iota // key was consumed, nothing further to do
pickerClose // close without saving
pickerSave // save the currently-selected device and close
pickerPropagate // key was not handled by the picker; caller may handle it
)
type devicePickerState struct {
devices []output.PlaybackDevice
index int // currently-highlighted row
current string // device name currently in effect (for the "(current)" annotation)
loadErr error // non-nil when ListPlaybackDevices failed
saveEnabled bool // false when no config path is available — save is disabled, Esc still works
}
// newDevicePicker builds the state shown when the user first opens the
// modal. listFn is the enumerator — parameterised so tests can stub it.
func newDevicePicker(listFn func() ([]output.PlaybackDevice, error), current string, saveEnabled bool) devicePickerState {
devices, err := listFn()
s := devicePickerState{
current: current,
loadErr: err,
saveEnabled: saveEnabled,
}
if err != nil {
return s
}
s.devices = devices
// Seed selection: current device if matched; else default; else first.
for i, d := range devices {
if d.Name == current {
s.index = i
return s
}
}
for i, d := range devices {
if d.IsDefault {
s.index = i
return s
}
}
return s
}
// handlePickerKey is a pure state transition used by both production and
// tests. It returns the next state and an action the caller performs (close,
// save, etc.). The Model translates actions into side effects.
func (s devicePickerState) handlePickerKey(key string) (devicePickerState, pickerAction) {
switch key {
case "up":
if len(s.devices) > 0 && s.index > 0 {
s.index--
}
return s, pickerNoop
case "down":
if s.index < len(s.devices)-1 {
s.index++
}
return s, pickerNoop
case "esc", "q":
return s, pickerClose
case "enter":
if !s.saveEnabled || len(s.devices) == 0 {
return s, pickerNoop
}
return s, pickerSave
}
return s, pickerPropagate
}
// selected returns the device the user would save if they pressed Enter
// right now, or nil when the list is empty or failed to load.
func (s devicePickerState) selected() *output.PlaybackDevice {
if s.loadErr != nil || len(s.devices) == 0 {
return nil
}
if s.index < 0 || s.index >= len(s.devices) {
return nil
}
return &s.devices[s.index]
}
// renderPicker draws the modal. width is the TUI width; the picker sizes
// itself accordingly. If the list failed to load, the error is surfaced
// inside the modal so the user knows why the picker is empty.
func renderPicker(s devicePickerState, width int) string {
if width < 60 {
width = 60
}
inner := width - 4
var b strings.Builder
b.WriteString("\u250c\u2500 Select playback device " + repeatString("\u2500", width-26) + "\u2510\n")
if s.loadErr != nil {
line := fmt.Sprintf(" %s", truncateRunes(s.loadErr.Error(), inner-2))
b.WriteString(fmt.Sprintf("\u2502 %-*s \u2502\n", inner, line))
} else if len(s.devices) == 0 {
b.WriteString(fmt.Sprintf("\u2502 %-*s \u2502\n", inner, " (no playback devices found)"))
} else {
for i, d := range s.devices {
marker := " "
if i == s.index {
marker = ">"
}
annotations := []string{}
if d.IsDefault {
annotations = append(annotations, "default")
}
if d.Name == s.current {
annotations = append(annotations, "current")
}
name := d.Name
if len(annotations) > 0 {
name = fmt.Sprintf("%s (%s)", d.Name, strings.Join(annotations, ", "))
}
line := fmt.Sprintf("%s %s", marker, truncateRunes(name, inner-2))
// Highlight the selected row with reverse video so it reads even
// when the user's terminal doesn't do dim/bright styling well.
if i == s.index {
line = ansiReverse + padRight(line, inner) + ansiReset
b.WriteString(fmt.Sprintf("\u2502 %s \u2502\n", line))
} else {
b.WriteString(fmt.Sprintf("\u2502 %-*s \u2502\n", inner, line))
}
}
}
// Blank row then the keybinding hints.
b.WriteString(fmt.Sprintf("\u2502 %-*s \u2502\n", inner, ""))
hints := " " + hotkey(KeyUpDown, "move") + " " + hotkey(KeyEnter, "save") + " " + hotkey(KeyEsc, "cancel")
if !s.saveEnabled {
hints = " " + hotkey(KeyUpDown, "move") + " save unavailable (no writable config path) " + hotkey(KeyEsc, "cancel")
}
// Padding accounts for the fact that hints contains ANSI escape codes
// that don't count toward display width.
displayLen := displayLen(hints)
pad := inner - displayLen
if pad < 0 {
pad = 0
}
b.WriteString(fmt.Sprintf("\u2502 %s%s \u2502\n", hints, strings.Repeat(" ", pad)))
b.WriteString("\u2514" + repeatString("\u2500", width-2) + "\u2518\n")
return b.String()
}
// displayLen approximates how many terminal columns a string takes, ignoring
// ANSI escape sequences. Close enough for alignment padding; we're not
// supporting combining characters or East Asian width here.
func displayLen(s string) int {
count := 0
inEsc := false
for _, r := range s {
if r == '\x1b' {
inEsc = true
continue
}
if inEsc {
if r == 'm' {
inEsc = false
}
continue
}
count++
}
return count
}
// truncateRunes returns s clipped to maxCols runes, appending an ellipsis
// when truncation actually happens. Rune-safe variant of model.go's
// byte-based truncate — device names may contain non-ASCII characters.
func truncateRunes(s string, maxCols int) string {
if len([]rune(s)) <= maxCols {
return s
}
if maxCols <= 1 {
return "\u2026"
}
runes := []rune(s)
return string(runes[:maxCols-1]) + "\u2026"
}
// padRight pads s with spaces up to width display columns. Used for the
// reverse-video row so the highlight extends across the full row.
func padRight(s string, width int) string {
d := displayLen(s)
if d >= width {
return s
}
return s + strings.Repeat(" ", width-d)
}

View File

@@ -0,0 +1,274 @@
// ABOUTME: Tests for the device-picker state machine — pure functions, no TUI loop required
package ui
import (
"errors"
"strings"
"testing"
"github.com/Sendspin/sendspin-go/pkg/audio/output"
)
func fixtureDevices() []output.PlaybackDevice {
return []output.PlaybackDevice{
{Name: "USB Audio"},
{Name: "Built-in Analog", IsDefault: true},
{Name: "HDMI"},
}
}
func listOK() ([]output.PlaybackDevice, error) {
return fixtureDevices(), nil
}
func TestNewDevicePicker_SeedsIndexFromCurrent(t *testing.T) {
s := newDevicePicker(listOK, "HDMI", true)
if s.index != 2 {
t.Errorf("index = %d, want 2 (HDMI)", s.index)
}
}
func TestNewDevicePicker_SeedsIndexFromDefaultWhenCurrentMissing(t *testing.T) {
s := newDevicePicker(listOK, "No such device", true)
if s.index != 1 {
t.Errorf("index = %d, want 1 (the IsDefault entry)", s.index)
}
}
func TestNewDevicePicker_SeedsZeroWhenNoDefaultAndNoCurrent(t *testing.T) {
listNoDefault := func() ([]output.PlaybackDevice, error) {
return []output.PlaybackDevice{
{Name: "One"},
{Name: "Two"},
}, nil
}
s := newDevicePicker(listNoDefault, "", true)
if s.index != 0 {
t.Errorf("index = %d, want 0", s.index)
}
}
func TestNewDevicePicker_PropagatesLoadError(t *testing.T) {
listErr := func() ([]output.PlaybackDevice, error) {
return nil, errors.New("no audio stack")
}
s := newDevicePicker(listErr, "", true)
if s.loadErr == nil {
t.Error("loadErr should be set when enumerator fails")
}
if s.selected() != nil {
t.Error("selected() should be nil when load failed")
}
}
func TestHandlePickerKey_UpDownClamp(t *testing.T) {
s := newDevicePicker(listOK, "USB Audio", true) // index 0
// Up at the top clamps.
s, act := s.handlePickerKey("up")
if s.index != 0 || act != pickerNoop {
t.Errorf("up at top: index=%d act=%d", s.index, act)
}
// Two downs.
s, _ = s.handlePickerKey("down")
s, _ = s.handlePickerKey("down")
if s.index != 2 {
t.Fatalf("after 2 downs, index = %d, want 2", s.index)
}
// Down at the bottom clamps.
s, _ = s.handlePickerKey("down")
if s.index != 2 {
t.Errorf("down at bottom: index = %d, want 2", s.index)
}
// One up.
s, _ = s.handlePickerKey("up")
if s.index != 1 {
t.Errorf("up from 2: index = %d, want 1", s.index)
}
}
func TestHandlePickerKey_EnterSavesWhenEnabled(t *testing.T) {
s := newDevicePicker(listOK, "", true)
_, act := s.handlePickerKey("enter")
if act != pickerSave {
t.Errorf("enter action = %d, want pickerSave", act)
}
}
func TestHandlePickerKey_EnterNoopWhenSaveDisabled(t *testing.T) {
s := newDevicePicker(listOK, "", false)
_, act := s.handlePickerKey("enter")
if act != pickerNoop {
t.Errorf("enter with save disabled action = %d, want pickerNoop", act)
}
}
func TestHandlePickerKey_EnterNoopOnEmptyList(t *testing.T) {
s := newDevicePicker(func() ([]output.PlaybackDevice, error) { return nil, nil }, "", true)
_, act := s.handlePickerKey("enter")
if act != pickerNoop {
t.Errorf("enter on empty action = %d, want pickerNoop", act)
}
}
func TestHandlePickerKey_EscAndQClose(t *testing.T) {
for _, key := range []string{"esc", "q"} {
s := newDevicePicker(listOK, "", true)
_, act := s.handlePickerKey(key)
if act != pickerClose {
t.Errorf("%q action = %d, want pickerClose", key, act)
}
}
}
func TestHandlePickerKey_UnknownPropagates(t *testing.T) {
s := newDevicePicker(listOK, "", true)
_, act := s.handlePickerKey("x")
if act != pickerPropagate {
t.Errorf("unknown key action = %d, want pickerPropagate", act)
}
}
func TestSelected_ReturnsCurrentRow(t *testing.T) {
s := newDevicePicker(listOK, "HDMI", true)
got := s.selected()
if got == nil {
t.Fatal("selected() = nil")
}
if got.Name != "HDMI" {
t.Errorf("selected().Name = %q, want HDMI", got.Name)
}
}
func TestRenderPicker_ShowsDefaultAndCurrentAnnotations(t *testing.T) {
s := newDevicePicker(listOK, "HDMI", true)
out := renderPicker(s, 80)
// Built-in Analog carries (default), HDMI carries (current).
if !strings.Contains(out, "Built-in Analog (default)") {
t.Errorf("expected '(default)' annotation; got:\n%s", out)
}
if !strings.Contains(out, "HDMI (current)") {
t.Errorf("expected '(current)' annotation on HDMI; got:\n%s", out)
}
}
func TestRenderPicker_AnnotatesBothWhenCurrentIsDefault(t *testing.T) {
s := newDevicePicker(listOK, "Built-in Analog", true)
out := renderPicker(s, 80)
if !strings.Contains(out, "Built-in Analog (default, current)") {
t.Errorf("expected combined annotation; got:\n%s", out)
}
}
func TestRenderPicker_EmptyAndErrorStates(t *testing.T) {
empty := newDevicePicker(func() ([]output.PlaybackDevice, error) { return nil, nil }, "", true)
emptyOut := renderPicker(empty, 80)
if !strings.Contains(emptyOut, "no playback devices found") {
t.Errorf("empty state did not render expected message; got:\n%s", emptyOut)
}
errState := newDevicePicker(func() ([]output.PlaybackDevice, error) {
return nil, errors.New("enumeration boom")
}, "", true)
errOut := renderPicker(errState, 80)
if !strings.Contains(errOut, "enumeration boom") {
t.Errorf("error state did not render error; got:\n%s", errOut)
}
}
func TestRenderPicker_SaveUnavailableHint(t *testing.T) {
s := newDevicePicker(listOK, "", false)
out := renderPicker(s, 80)
if !strings.Contains(out, "save unavailable") {
t.Errorf("expected 'save unavailable' hint when saveEnabled=false; got:\n%s", out)
}
}
// The two tests below integrate picker state with the Model's key dispatch
// to verify the end-to-end "press o → navigate → save" flow without
// standing up a full bubbletea runtime.
func TestModel_OKeyOpensPicker(t *testing.T) {
m := NewModel(Config{ConfigPath: "/tmp/test-player.yaml"})
m.listPlaybackDevices = listOK
m.width = 80
next, _ := m.handleKey(fakeKeyMsg("o"))
nm := next.(Model)
if nm.mode != modeDevicePicker {
t.Errorf("mode = %v, want modeDevicePicker", nm.mode)
}
if len(nm.picker.devices) != 3 {
t.Errorf("picker.devices len = %d, want 3", len(nm.picker.devices))
}
}
func TestModel_EnterInPickerSavesAndClosesModal(t *testing.T) {
var savedPath, savedKey, savedValue string
writer := func(path, key, value string) error {
savedPath, savedKey, savedValue = path, key, value
return nil
}
// Seed the current device so the picker opens highlighted on "USB Audio"
// (index 0). One "down" then lands on the IsDefault entry at index 1.
m := NewModel(Config{ConfigPath: "/tmp/test-player.yaml", AudioDevice: "USB Audio"})
m.listPlaybackDevices = listOK
m.writeConfigKey = writer
m.width = 80
// Open picker.
next, _ := m.handleKey(fakeKeyMsg("o"))
m = next.(Model)
// Navigate down to Built-in Analog (index 1).
next, _ = m.handleKey(fakeKeyMsg("down"))
m = next.(Model)
// Enter saves. Returns a Cmd (the 3s expire tick); we don't invoke it.
next, cmd := m.handleKey(fakeKeyMsg("enter"))
m = next.(Model)
if m.mode != modeNormal {
t.Errorf("mode after save = %v, want modeNormal", m.mode)
}
if savedPath != "/tmp/test-player.yaml" || savedKey != "audio_device" || savedValue != "Built-in Analog" {
t.Errorf("write recorded (%q, %q, %q); want (/tmp/test-player.yaml, audio_device, Built-in Analog)",
savedPath, savedKey, savedValue)
}
if m.transientMsg == "" {
t.Error("transient banner should be set after save")
}
if cmd == nil {
t.Error("save should schedule a transient-expire tick")
}
}
func TestModel_EnterWhenSelectionMatchesSkipsWrite(t *testing.T) {
writeCalled := false
writer := func(path, key, value string) error {
writeCalled = true
return nil
}
// Current is the HDMI entry; picker seeds selection there.
m := NewModel(Config{ConfigPath: "/tmp/test.yaml", AudioDevice: "HDMI"})
m.listPlaybackDevices = listOK
m.writeConfigKey = writer
m.width = 80
next, _ := m.handleKey(fakeKeyMsg("o"))
m = next.(Model)
next, _ = m.handleKey(fakeKeyMsg("enter"))
m = next.(Model)
if writeCalled {
t.Error("writeConfigKey should not be called when selection matches current")
}
if m.mode != modeNormal {
t.Errorf("mode = %v, want modeNormal", m.mode)
}
}

View File

@@ -0,0 +1,115 @@
// ABOUTME: Hotkey label rendering helper — marks the trigger character with reverse video
// ABOUTME: Used by all TUI labels that advertise a keybinding so the cue is uniform
package ui
import (
"strings"
"unicode"
)
// ANSI escape sequences for SGR reverse-video. Kept here rather than in a
// styling library because we only need two codes — pulling in lipgloss for
// this would be overkill.
const (
ansiReverse = "\x1b[7m"
ansiReset = "\x1b[0m"
)
// hotkey renders a human-readable label with its trigger key reverse-
// highlighted. The visual cue — a single inverted character embedded in the
// label — is uniform across every keybinding in the TUI so users learn the
// pattern once.
//
// Resolution rules:
// - Printable letter trigger with a case-insensitive match in the label:
// highlight the first occurrence in place.
// hotkey('o', "Output") -> "\x1b[7mO\x1b[0mutput"
// - Printable letter trigger NOT present in the label:
// fall back to a bracket prefix.
// hotkey('q', "Bye") -> "[\x1b[7mQ\x1b[0m] Bye"
// - Non-letter triggers (space, arrow sentinels, etc.) always use the
// bracket form with a friendly name for the key.
// hotkey(KeySpace, "Play/Pause") -> "[\x1b[7mSpace\x1b[0m] Play/Pause"
//
// Empty labels still render the bracket prefix so the binding is visible.
func hotkey(trigger rune, label string) string {
if name, ok := specialKeyName(trigger); ok {
return bracketPrefix(name, label)
}
if !unicode.IsLetter(trigger) && !unicode.IsDigit(trigger) {
// Punctuation or symbols — use bracket form with the literal rune.
return bracketPrefix(string(trigger), label)
}
if idx := firstCaseFoldIndex(label, trigger); idx >= 0 {
r, width := runeAt(label, idx)
return label[:idx] + ansiReverse + string(r) + ansiReset + label[idx+width:]
}
return bracketPrefix(strings.ToUpper(string(trigger)), label)
}
// Special-key sentinels. These aren't valid Unicode code points that could
// appear in a label, so overloading a rune-sized value is safe. Any value
// outside the printable range works; using specific private-use-area code
// points keeps them self-documenting in stack traces.
const (
KeySpace rune = '\uE000' // "Space"
KeyUp rune = '\uE001' // "↑"
KeyDown rune = '\uE002' // "↓"
KeyUpDown rune = '\uE003' // "↑↓"
KeyEnter rune = '\uE004' // "Enter"
KeyEsc rune = '\uE005' // "Esc"
)
func specialKeyName(r rune) (string, bool) {
switch r {
case KeySpace:
return "Space", true
case KeyUp:
return "\u2191", true
case KeyDown:
return "\u2193", true
case KeyUpDown:
return "\u2191\u2193", true
case KeyEnter:
return "Enter", true
case KeyEsc:
return "Esc", true
}
return "", false
}
// bracketPrefix renders "[<highlighted keyName>] label" with the key name
// reverse-highlighted. When label is empty, emits just the bracket (still
// useful so the binding is visible in compact hint rows).
func bracketPrefix(keyName, label string) string {
prefix := "[" + ansiReverse + keyName + ansiReset + "]"
if label == "" {
return prefix
}
return prefix + " " + label
}
// firstCaseFoldIndex returns the byte index of the first rune in s that
// case-folds equal to needle, or -1 if not found. Works for any letter —
// multi-byte ones included, though this codebase only uses ASCII triggers.
func firstCaseFoldIndex(s string, needle rune) int {
target := unicode.ToLower(needle)
for i, r := range s {
if unicode.ToLower(r) == target {
return i
}
}
return -1
}
// runeAt returns the rune at byte index i and its encoded byte width. Only
// called after firstCaseFoldIndex has confirmed a match, so the index is
// guaranteed to be on a rune boundary.
func runeAt(s string, i int) (rune, int) {
for j, r := range s {
if j == i {
return r, len(string(r))
}
}
return 0, 0
}

View File

@@ -0,0 +1,123 @@
// ABOUTME: Tests for the hotkey label helper — in-place reverse highlighting
package ui
import "testing"
func TestHotkey_LetterInLabel(t *testing.T) {
tests := []struct {
name string
trigger rune
label string
want string
}{
{
name: "first letter match",
trigger: 'o',
label: "Output",
want: ansiReverse + "O" + ansiReset + "utput",
},
{
name: "case-insensitive — lowercase trigger, uppercase in label",
trigger: 'q',
label: "Quit",
want: ansiReverse + "Q" + ansiReset + "uit",
},
{
name: "case-insensitive — uppercase trigger, lowercase in label",
trigger: 'E',
label: "enter",
want: ansiReverse + "e" + ansiReset + "nter",
},
{
name: "trigger matches a non-initial letter",
trigger: 'u',
label: "Quit",
want: "Q" + ansiReverse + "u" + ansiReset + "it",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := hotkey(tt.trigger, tt.label)
if got != tt.want {
t.Errorf("hotkey(%q, %q) =\n %q\nwant\n %q", tt.trigger, tt.label, got, tt.want)
}
})
}
}
func TestHotkey_LetterNotInLabel(t *testing.T) {
got := hotkey('x', "Bye")
want := "[" + ansiReverse + "X" + ansiReset + "] Bye"
if got != want {
t.Errorf("missing-letter fallback =\n %q\nwant\n %q", got, want)
}
}
func TestHotkey_SpecialKeys(t *testing.T) {
tests := []struct {
name string
trigger rune
label string
want string
}{
{
name: "space",
trigger: KeySpace,
label: "Play/Pause",
want: "[" + ansiReverse + "Space" + ansiReset + "] Play/Pause",
},
{
name: "up-down combined",
trigger: KeyUpDown,
label: "Volume",
want: "[" + ansiReverse + "\u2191\u2193" + ansiReset + "] Volume",
},
{
name: "enter",
trigger: KeyEnter,
label: "Save",
want: "[" + ansiReverse + "Enter" + ansiReset + "] Save",
},
{
name: "esc",
trigger: KeyEsc,
label: "Cancel",
want: "[" + ansiReverse + "Esc" + ansiReset + "] Cancel",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := hotkey(tt.trigger, tt.label)
if got != tt.want {
t.Errorf("hotkey(%q) =\n %q\nwant\n %q", tt.trigger, got, tt.want)
}
})
}
}
func TestHotkey_EmptyLabel(t *testing.T) {
// Letter with empty label uses the bracket fallback since nothing to match.
got := hotkey('q', "")
want := "[" + ansiReverse + "Q" + ansiReset + "]"
if got != want {
t.Errorf("empty label with letter =\n %q\nwant\n %q", got, want)
}
// Special key with empty label uses bracket without trailing space.
got = hotkey(KeyEsc, "")
want = "[" + ansiReverse + "Esc" + ansiReset + "]"
if got != want {
t.Errorf("empty label with special =\n %q\nwant\n %q", got, want)
}
}
func TestHotkey_Symbol(t *testing.T) {
// Symbols aren't letters; they use the bracket prefix with the literal rune.
got := hotkey('/', "Slash")
want := "[" + ansiReverse + "/" + ansiReset + "] Slash"
if got != want {
t.Errorf("symbol trigger =\n %q\nwant\n %q", got, want)
}
}

View File

@@ -0,0 +1,625 @@
// ABOUTME: Bubbletea model for player TUI
// ABOUTME: Defines application state and update logic
package ui
import (
"fmt"
"strings"
"time"
"github.com/Sendspin/sendspin-go/pkg/audio/output"
"github.com/Sendspin/sendspin-go/pkg/sendspin"
"github.com/Sendspin/sendspin-go/pkg/sync"
tea "github.com/charmbracelet/bubbletea"
)
type uiMode int
const (
modeNormal uiMode = iota
modeDevicePicker
)
// transientExpireMsg is fired after a transient banner's lifetime to clear
// it. Bubbletea's scheduler guarantees at-most-once delivery.
type transientExpireMsg struct{ nonce uint64 }
// Model represents the TUI state
type Model struct {
// Connection
connected bool
serverName string
// Sync
syncRTT int64
syncQuality sync.Quality
// Stream
codec string
sampleRate int
channels int
bitDepth int
// Metadata
title string
artist string
album string
artworkPath string
// Playback
state string
playbackState string // "playing", "paused", "stopped", "idle", "reconnecting"
volume int
muted bool
// Stats
received int64
played int64
dropped int64
bufferDepth int
// Debug
showDebug bool
goroutines int
// Dimensions
width int
height int
// Controls
volumeCtrl *VolumeControl
transportCtrl *TransportControl
// Audio device selection
audioDevice string // current device driving playback; shown in status row
configPath string // where picker saves audio_device; empty disables save
// Modal state
mode uiMode
picker devicePickerState
// Transient banner shown in the controls area (e.g. "Saved. Restart to apply.")
transientMsg string
transientNonce uint64 // incremented per banner so stale expire ticks are ignored
// listPlaybackDevices is indirected so tests can stub it.
listPlaybackDevices func() ([]output.PlaybackDevice, error)
// writeConfigKey persists a key to the config file. Indirected for tests.
writeConfigKey func(path, key, value string) error
}
func (m Model) Init() tea.Cmd {
return nil
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
return m.handleKey(msg)
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
case StatusMsg:
m.applyStatus(msg)
case transientExpireMsg:
if msg.nonce == m.transientNonce {
m.transientMsg = ""
}
}
return m, nil
}
func (m Model) View() string {
if m.width == 0 {
return "Loading..."
}
if m.mode == modeDevicePicker {
// Modal takes over the viewport; the normal view is suspended. A
// minimal header keeps context ("which player am I configuring?").
return m.renderHeader() + renderPicker(m.picker, m.width)
}
s := ""
s += m.renderHeader()
s += m.renderStreamInfo()
s += m.renderControls()
s += m.renderStats()
if m.showDebug {
s += m.renderDebug()
}
s += m.renderHelp()
return s
}
func (m Model) renderHeader() string {
connStatus := "Disconnected"
if m.connected {
connStatus = fmt.Sprintf("Connected to %s", m.serverName)
}
stateDisplay := playbackStateDisplay(m.playbackState)
syncDisplay := "Sync: \u2717 Lost"
switch m.syncQuality {
case sync.QualityGood:
syncDisplay = fmt.Sprintf("Sync: \u2713 Good (RTT: %.1fms)", float64(m.syncRTT)/1000.0)
case sync.QualityDegraded:
syncDisplay = "Sync: \u26a0 Degraded"
}
// Use terminal width for responsive layout
width := m.width
if width < 60 {
width = 60 // Minimum width
}
innerWidth := width - 4 // Account for borders
titleWidth := width - 20 // Space for "┌─ Sendspin Player " prefix
title := "\u250c\u2500 Sendspin Player " + repeatString("\u2500", titleWidth) + "\u2510\n"
statusLine := fmt.Sprintf("\u2502 Status: %-*s \u2502\n", innerWidth-9, truncate(connStatus, innerWidth-9))
// State + Sync on same line
statePrefix := fmt.Sprintf("State: %s", stateDisplay)
// Calculate available space: innerWidth minus "State: <state>" minus spacing minus sync display
stateSyncLine := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth,
fmt.Sprintf("%-30s %s", statePrefix, syncDisplay))
separator := "\u251c" + repeatString("\u2500", width-2) + "\u2524\n"
return title + statusLine + stateSyncLine + separator
}
func (m Model) renderStreamInfo() string {
width := m.width
if width < 60 {
width = 60
}
innerWidth := width - 4
if !m.connected || m.codec == "" {
return fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, "No stream")
}
s := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, "Now Playing:")
if m.title != "" {
metaWidth := innerWidth - 10 // Account for " Track: " prefix
s += fmt.Sprintf("\u2502 Track: %-*s \u2502\n", innerWidth-10, truncate(m.title, metaWidth))
s += fmt.Sprintf("\u2502 Artist: %-*s \u2502\n", innerWidth-10, truncate(m.artist, metaWidth))
s += fmt.Sprintf("\u2502 Album: %-*s \u2502\n", innerWidth-10, truncate(m.album, metaWidth))
if m.artworkPath != "" {
s += fmt.Sprintf("\u2502 Art: %-*s \u2502\n", innerWidth-10, truncate(m.artworkPath, metaWidth))
}
} else {
s += fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth-3, "(No metadata)")
}
s += fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, "")
formatStr := formatCodecDisplay(m.codec, m.sampleRate, m.bitDepth)
s += fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, formatStr)
return s
}
func (m Model) renderControls() string {
width := m.width
if width < 60 {
width = 60
}
innerWidth := width - 4
muteIcon := ""
if m.muted {
muteIcon = " \U0001f507"
}
volumeBar := renderBar(m.volume, 100, 20)
s := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, "")
volumeStr := fmt.Sprintf("Volume: [%s] %d%%%s", volumeBar, m.volume, muteIcon)
s += fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, volumeStr)
bufferStr := fmt.Sprintf("Buffer: %dms", m.bufferDepth)
s += fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, bufferStr)
// Output device status row. Uses the hotkey helper so the 'O' rendering
// stays in sync with the help line that advertises the trigger.
outputName := m.audioDevice
if outputName == "" {
outputName = "(miniaudio default)"
}
outputLine := hotkey('o', "Output: "+truncate(outputName, innerWidth-10))
s += renderLineWithANSI(innerWidth, outputLine)
if m.transientMsg != "" {
s += renderLineWithANSI(innerWidth, " "+m.transientMsg)
}
return s
}
// renderLineWithANSI emits a bordered row whose contents contain ANSI
// escape sequences. fmt's padding verbs count those escape bytes toward
// width, leading to short rows; this helper pads using visible-column
// count instead so every border stays aligned.
func renderLineWithANSI(innerWidth int, content string) string {
pad := innerWidth - displayLen(content)
if pad < 0 {
pad = 0
}
return fmt.Sprintf("\u2502 %s%s \u2502\n", content, strings.Repeat(" ", pad))
}
func (m Model) renderStats() string {
width := m.width
if width < 60 {
width = 60
}
innerWidth := width - 4
separator := "\u251c" + repeatString("\u2500", width-2) + "\u2524\n"
statsStr := fmt.Sprintf("Stats: RX: %d Played: %d Dropped: %d", m.received, m.played, m.dropped)
statsLine := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, statsStr)
emptyLine := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, "")
return separator + statsLine + emptyLine
}
func (m Model) renderHelp() string {
width := m.width
if width < 60 {
width = 60
}
innerWidth := width - 4
// Uniform hotkey rendering — every trigger character is reverse-
// highlighted inline via hotkey(). Separators are two spaces; we rely
// on renderLineWithANSI for correct padding because hotkey() emits
// escape codes that fmt would miscount.
parts := []string{
hotkey(KeySpace, "Play/Pause"),
hotkey('n', "Next"),
hotkey('p', "Prev"),
hotkey(KeyUpDown, "Volume"),
hotkey('m', "Mute"),
hotkey('o', "Output"),
hotkey('q', "Quit"),
}
helpStr := strings.Join(parts, " ")
helpLine := renderLineWithANSI(innerWidth, helpStr)
bottom := "\u2514" + repeatString("\u2500", width-2) + "\u2518\n"
return helpLine + bottom
}
func (m Model) renderDebug() string {
width := m.width
if width < 60 {
width = 60
}
innerWidth := width - 4
debugTitle := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, "DEBUG:")
goroutineStr := fmt.Sprintf(" Goroutines: %d", m.goroutines)
goroutineLine := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, goroutineStr)
playbackStr := fmt.Sprintf(" Playback: %s", m.playbackState)
playbackLine := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, playbackStr)
codecStr := fmt.Sprintf(" Preferred codec: %s", m.codec)
codecLine := fmt.Sprintf("\u2502 %-*s \u2502\n", innerWidth, codecStr)
return debugTitle + goroutineLine + playbackLine + codecLine
}
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.mode == modeDevicePicker {
return m.handlePickerMode(msg)
}
switch msg.String() {
case "o":
return m.openDevicePicker()
case "q", "ctrl+c":
if m.volumeCtrl != nil {
select {
case m.volumeCtrl.Quit <- QuitMsg{}:
default:
// Channel full, skip
}
}
return m, tea.Quit
case "up":
if m.volume < 100 {
m.volume += 5
if m.volume > 100 {
m.volume = 100
}
if m.volumeCtrl != nil {
select {
case m.volumeCtrl.Changes <- VolumeChangeMsg{Volume: m.volume, Muted: m.muted}:
default:
// Channel full, skip
}
}
}
case "down":
if m.volume > 0 {
m.volume -= 5
if m.volume < 0 {
m.volume = 0
}
if m.volumeCtrl != nil {
select {
case m.volumeCtrl.Changes <- VolumeChangeMsg{Volume: m.volume, Muted: m.muted}:
default:
// Channel full, skip
}
}
}
case "m":
m.muted = !m.muted
// Send volume change to player via channel
if m.volumeCtrl != nil {
select {
case m.volumeCtrl.Changes <- VolumeChangeMsg{Volume: m.volume, Muted: m.muted}:
default:
// Channel full, skip
}
}
case " ":
if m.transportCtrl != nil {
select {
case m.transportCtrl.Commands <- TransportMsg{Command: "toggle"}:
default:
}
}
case "n":
if m.transportCtrl != nil {
select {
case m.transportCtrl.Commands <- TransportMsg{Command: "next"}:
default:
}
}
case "p":
if m.transportCtrl != nil {
select {
case m.transportCtrl.Commands <- TransportMsg{Command: "previous"}:
default:
}
}
case "r":
if m.transportCtrl != nil {
select {
case m.transportCtrl.Commands <- TransportMsg{Command: "reconnect"}:
default:
}
}
case "d":
m.showDebug = !m.showDebug
}
return m, nil
}
func (m *Model) applyStatus(msg StatusMsg) {
if msg.Connected != nil {
m.connected = *msg.Connected
}
if msg.ServerName != "" {
m.serverName = msg.ServerName
}
if msg.PlaybackState != "" {
m.playbackState = msg.PlaybackState
}
// Sync stats are always applied when sent
if msg.SyncRTT != 0 {
m.syncRTT = msg.SyncRTT
m.syncQuality = msg.SyncQuality
}
if msg.Codec != "" {
m.codec = msg.Codec
m.sampleRate = msg.SampleRate
m.channels = msg.Channels
m.bitDepth = msg.BitDepth
}
if msg.Title != "" {
m.title = msg.Title
m.artist = msg.Artist
m.album = msg.Album
}
if msg.ArtworkPath != "" {
m.artworkPath = msg.ArtworkPath
}
// Volume is always applied when explicitly sent (can be 0 for silent)
// We rely on caller not sending Volume=0 in messages unless it's intentional
if msg.Volume != 0 {
m.volume = msg.Volume
}
// Always apply stats - they can legitimately be zero
m.received = msg.Received
m.played = msg.Played
m.dropped = msg.Dropped
m.bufferDepth = msg.BufferDepth
m.goroutines = msg.Goroutines
}
type StatusMsg struct {
Connected *bool
ServerName string
PlaybackState string
SyncRTT int64
SyncQuality sync.Quality
Codec string
SampleRate int
Channels int
BitDepth int
Title string
Artist string
Album string
ArtworkPath string
Volume int
Received int64
Played int64
Dropped int64
BufferDepth int
Goroutines int
}
type VolumeChangeMsg struct {
Volume int
Muted bool
}
type QuitMsg struct{}
func renderBar(value, max, width int) string {
filled := (value * width) / max
return strings.Repeat("\u2588", filled) + strings.Repeat("\u2591", width-filled)
}
func truncate(s string, length int) string {
if len(s) <= length {
return s
}
return s[:length-3] + "..."
}
func channelName(channels int) string {
if channels == 1 {
return "Mono"
}
return "Stereo"
}
func repeatString(s string, count int) string {
if count <= 0 {
return ""
}
return strings.Repeat(s, count)
}
// playbackStateDisplay returns a display string with icon for the given playback state.
func playbackStateDisplay(state string) string {
switch state {
case "playing":
return "\u25b6 Playing"
case "paused":
return "\u23f8 Paused"
case "stopped":
return "\u23f9 Stopped"
case "idle":
return "\u25cf Idle"
case "reconnecting":
return "\u21bb Reconnecting..."
default:
return "\u25cf Idle"
}
}
// formatSampleRate returns a human-friendly sample rate string.
func formatSampleRate(rate int) string {
switch rate {
case 44100:
return "44.1kHz"
case 48000:
return "48kHz"
case 88200:
return "88.2kHz"
case 96000:
return "96kHz"
case 176400:
return "176.4kHz"
case 192000:
return "192kHz"
default:
if rate%1000 == 0 {
return fmt.Sprintf("%dkHz", rate/1000)
}
return fmt.Sprintf("%.1fkHz", float64(rate)/1000.0)
}
}
// formatCodecDisplay returns a rich codec description string.
func formatCodecDisplay(codec string, sampleRate, bitDepth int) string {
rate := formatSampleRate(sampleRate)
upper := strings.ToUpper(codec)
switch strings.ToLower(codec) {
case "pcm":
return fmt.Sprintf("%s %s/%dbit lossless", upper, rate, bitDepth)
case "flac":
return fmt.Sprintf("%s %s/%dbit lossless", upper, rate, bitDepth)
case "opus":
return fmt.Sprintf("%s %s/%dbit", upper, rate, bitDepth)
default:
return fmt.Sprintf("%s %s/%dbit", upper, rate, bitDepth)
}
}
// openDevicePicker transitions the Model into modeDevicePicker, enumerating
// available devices via the injected lister (defaulting to the real
// output.ListPlaybackDevices). The picker's own state tracks enumeration
// errors, so we never fail to enter the modal — the user always gets to
// see *something*, even if it's "couldn't enumerate devices".
func (m Model) openDevicePicker() (tea.Model, tea.Cmd) {
lister := m.listPlaybackDevices
if lister == nil {
lister = output.ListPlaybackDevices
}
saveEnabled := m.configPath != ""
m.picker = newDevicePicker(lister, m.audioDevice, saveEnabled)
m.mode = modeDevicePicker
return m, nil
}
// handlePickerMode dispatches a key to the picker's pure state machine and
// executes the requested action. Save writes audio_device to the config
// file and shows a transient "Saved. Restart to apply." banner for 3s.
func (m Model) handlePickerMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
next, action := m.picker.handlePickerKey(msg.String())
m.picker = next
switch action {
case pickerNoop:
return m, nil
case pickerClose, pickerPropagate:
m.mode = modeNormal
return m, nil
case pickerSave:
chosen := m.picker.selected()
if chosen == nil {
m.mode = modeNormal
return m, nil
}
writer := m.writeConfigKey
if writer == nil {
writer = sendspin.WriteStringKey
}
if chosen.Name != m.audioDevice {
// No-op when the chosen value already matches what's saved —
// matches the client_id write-back behavior and avoids
// churning user-edited config files for no reason.
if err := writer(m.configPath, "audio_device", chosen.Name); err != nil {
m.mode = modeNormal
return m.withTransient(fmt.Sprintf("Save failed: %v", err))
}
}
m.mode = modeNormal
return m.withTransient(fmt.Sprintf("Saved audio_device=%q. Restart to apply.", chosen.Name))
}
return m, nil
}
// withTransient schedules msg to be shown in the status row for ~3 seconds.
// The nonce guards against a later expire firing for a previously-shown
// banner that was already overwritten by a newer one.
func (m Model) withTransient(msg string) (tea.Model, tea.Cmd) {
m.transientNonce++
m.transientMsg = msg
nonce := m.transientNonce
return m, tea.Tick(3*time.Second, func(time.Time) tea.Msg {
return transientExpireMsg{nonce: nonce}
})
}

View File

@@ -0,0 +1,561 @@
// ABOUTME: Tests for TUI model and state management
// ABOUTME: Tests status updates, message handling, and state transitions
package ui
import (
"testing"
"github.com/Sendspin/sendspin-go/pkg/sync"
tea "github.com/charmbracelet/bubbletea"
)
func TestNewModel(t *testing.T) {
model := NewModel(Config{}) // VolumeControl and TransportControl are optional for testing
// Check initial state
if model.connected {
t.Error("expected connected to be false initially")
}
if model.volume != 100 {
t.Errorf("expected default volume 100, got %d", model.volume)
}
if model.muted {
t.Error("expected muted to be false initially")
}
if model.showDebug {
t.Error("expected showDebug to be false initially")
}
if model.playbackState != "idle" {
t.Errorf("expected playbackState 'idle', got '%s'", model.playbackState)
}
}
func TestStatusMsgConnected(t *testing.T) {
model := NewModel(Config{})
connected := true
msg := StatusMsg{
Connected: &connected,
ServerName: "test-server",
}
model.applyStatus(msg)
if !model.connected {
t.Error("expected connected to be true after status update")
}
if model.serverName != "test-server" {
t.Errorf("expected serverName 'test-server', got '%s'", model.serverName)
}
}
func TestStatusMsgDisconnected(t *testing.T) {
model := NewModel(Config{})
// First connect
connected := true
model.applyStatus(StatusMsg{Connected: &connected})
// Then disconnect
disconnected := false
model.applyStatus(StatusMsg{Connected: &disconnected})
if model.connected {
t.Error("expected connected to be false after disconnect")
}
}
func TestStatusMsgSyncStats(t *testing.T) {
model := NewModel(Config{})
msg := StatusMsg{
SyncRTT: 5000,
SyncQuality: sync.QualityGood,
}
model.applyStatus(msg)
if model.syncRTT != 5000 {
t.Errorf("expected syncRTT 5000, got %d", model.syncRTT)
}
if model.syncQuality != sync.QualityGood {
t.Errorf("expected QualityGood, got %v", model.syncQuality)
}
}
func TestStatusMsgStreamInfo(t *testing.T) {
model := NewModel(Config{})
msg := StatusMsg{
Codec: "opus",
SampleRate: 48000,
Channels: 2,
BitDepth: 16,
}
model.applyStatus(msg)
if model.codec != "opus" {
t.Errorf("expected codec 'opus', got '%s'", model.codec)
}
if model.sampleRate != 48000 {
t.Errorf("expected sampleRate 48000, got %d", model.sampleRate)
}
if model.channels != 2 {
t.Errorf("expected channels 2, got %d", model.channels)
}
if model.bitDepth != 16 {
t.Errorf("expected bitDepth 16, got %d", model.bitDepth)
}
}
func TestStatusMsgMetadata(t *testing.T) {
model := NewModel(Config{})
msg := StatusMsg{
Title: "Test Song",
Artist: "Test Artist",
Album: "Test Album",
}
model.applyStatus(msg)
if model.title != "Test Song" {
t.Errorf("expected title 'Test Song', got '%s'", model.title)
}
if model.artist != "Test Artist" {
t.Errorf("expected artist 'Test Artist', got '%s'", model.artist)
}
if model.album != "Test Album" {
t.Errorf("expected album 'Test Album', got '%s'", model.album)
}
}
func TestStatusMsgArtworkPath(t *testing.T) {
model := NewModel(Config{})
msg := StatusMsg{
ArtworkPath: "/tmp/artwork.jpg",
}
model.applyStatus(msg)
if model.artworkPath != "/tmp/artwork.jpg" {
t.Errorf("expected artworkPath '/tmp/artwork.jpg', got '%s'", model.artworkPath)
}
}
func TestStatusMsgVolume(t *testing.T) {
model := NewModel(Config{})
msg := StatusMsg{
Volume: 75,
}
model.applyStatus(msg)
if model.volume != 75 {
t.Errorf("expected volume 75, got %d", model.volume)
}
}
func TestStatusMsgStats(t *testing.T) {
model := NewModel(Config{})
msg := StatusMsg{
Received: 1000,
Played: 950,
Dropped: 50,
BufferDepth: 300,
}
model.applyStatus(msg)
if model.received != 1000 {
t.Errorf("expected received 1000, got %d", model.received)
}
if model.played != 950 {
t.Errorf("expected played 950, got %d", model.played)
}
if model.dropped != 50 {
t.Errorf("expected dropped 50, got %d", model.dropped)
}
if model.bufferDepth != 300 {
t.Errorf("expected bufferDepth 300, got %d", model.bufferDepth)
}
}
func TestStatusMsgRuntimeStats(t *testing.T) {
model := NewModel(Config{})
msg := StatusMsg{
Goroutines: 42,
}
model.applyStatus(msg)
if model.goroutines != 42 {
t.Errorf("expected goroutines 42, got %d", model.goroutines)
}
}
func TestMultipleStatusUpdates(t *testing.T) {
model := NewModel(Config{})
// First update
connected := true
model.applyStatus(StatusMsg{
Connected: &connected,
Codec: "opus",
})
if model.codec != "opus" {
t.Error("first update failed")
}
// Second update (partial) - sampleRate requires Codec to be in same message
model.applyStatus(StatusMsg{
Codec: "opus",
SampleRate: 48000,
})
// Previous values should be retained
if model.codec != "opus" {
t.Error("previous codec value was lost")
}
if model.sampleRate != 48000 {
t.Error("new sampleRate not applied")
}
}
func TestStatusMsgZeroValues(t *testing.T) {
model := NewModel(Config{})
// Set some non-zero values first
model.applyStatus(StatusMsg{
Volume: 75,
Received: 100,
})
// Apply zero values - Volume should not update (0 is ignored), but stats should
model.applyStatus(StatusMsg{
Volume: 0,
Received: 0,
})
// Volume should NOT be updated to 0 (0 is special case)
if model.volume == 0 {
t.Error("volume should not be updated to 0")
}
// Stats should be updated (0 is valid)
if model.received != 0 {
t.Error("received stats should be updated to 0")
}
}
func TestTruncateFunction(t *testing.T) {
tests := []struct {
input string
maxLen int
expected string
}{
{"short", 10, "short"},
{"exactly ten c", 14, "exactly ten c"},
{"this is longer than allowed", 10, "this is..."},
{"this is longer than allowed", 15, "this is long..."}, // 15-3=12 chars + "..."
{"", 10, ""},
{"a", 10, "a"},
{"abc", 3, "abc"},
{"abcd", 4, "abcd"},
{"abcde", 4, "a..."},
}
for _, tt := range tests {
result := truncate(tt.input, tt.maxLen)
if result != tt.expected {
t.Errorf("truncate(%q, %d) = %q, expected %q",
tt.input, tt.maxLen, result, tt.expected)
}
}
}
func TestChannelNameFunction(t *testing.T) {
tests := []struct {
channels int
expected string
}{
{1, "Mono"},
{2, "Stereo"},
{3, "Stereo"}, // Function only distinguishes 1 vs other
{6, "Stereo"},
{0, "Stereo"},
}
for _, tt := range tests {
result := channelName(tt.channels)
if result != tt.expected {
t.Errorf("channelName(%d) = %q, expected %q",
tt.channels, result, tt.expected)
}
}
}
func TestSyncQualityDisplay(t *testing.T) {
model := NewModel(Config{})
// Test different quality levels
// Note: quality is only applied when SyncRTT is non-zero
qualities := []sync.Quality{
sync.QualityGood,
sync.QualityDegraded,
sync.QualityLost,
}
for _, q := range qualities {
model.applyStatus(StatusMsg{
SyncQuality: q,
SyncRTT: 1000, // Must include RTT for quality to be applied
})
if model.syncQuality != q {
t.Errorf("quality not updated to %v", q)
}
}
}
func TestMetadataClearing(t *testing.T) {
model := NewModel(Config{})
// Set metadata
model.applyStatus(StatusMsg{
Title: "Song",
Artist: "Artist",
Album: "Album",
})
// Clear metadata with empty strings
model.applyStatus(StatusMsg{
Title: "",
Artist: "",
Album: "",
})
// Empty strings should not clear (only non-empty values are applied)
if model.title != "Song" {
t.Error("title should not be cleared by empty string")
}
}
func TestPlaybackStateApplication(t *testing.T) {
model := NewModel(Config{})
// Initial state should be idle
if model.playbackState != "idle" {
t.Errorf("expected initial playbackState 'idle', got '%s'", model.playbackState)
}
// Apply playing state
model.applyStatus(StatusMsg{PlaybackState: "playing"})
if model.playbackState != "playing" {
t.Errorf("expected playbackState 'playing', got '%s'", model.playbackState)
}
// Apply paused state
model.applyStatus(StatusMsg{PlaybackState: "paused"})
if model.playbackState != "paused" {
t.Errorf("expected playbackState 'paused', got '%s'", model.playbackState)
}
// Empty string should not overwrite
model.applyStatus(StatusMsg{PlaybackState: ""})
if model.playbackState != "paused" {
t.Errorf("expected playbackState to remain 'paused', got '%s'", model.playbackState)
}
}
func TestTransportKeyToggle(t *testing.T) {
tc := NewTransportControl()
model := NewModel(Config{TransportCtrl: tc})
model.width = 80
model.height = 24
// Simulate space key
msg := fakeKeyMsg(" ")
model.handleKey(msg)
select {
case cmd := <-tc.Commands:
if cmd.Command != "toggle" {
t.Errorf("expected 'toggle' command, got '%s'", cmd.Command)
}
default:
t.Error("expected toggle command on transport channel")
}
}
func TestTransportKeyNext(t *testing.T) {
tc := NewTransportControl()
model := NewModel(Config{TransportCtrl: tc})
model.width = 80
model.height = 24
msg := fakeKeyMsg("n")
model.handleKey(msg)
select {
case cmd := <-tc.Commands:
if cmd.Command != "next" {
t.Errorf("expected 'next' command, got '%s'", cmd.Command)
}
default:
t.Error("expected next command on transport channel")
}
}
func TestTransportKeyPrevious(t *testing.T) {
tc := NewTransportControl()
model := NewModel(Config{TransportCtrl: tc})
model.width = 80
model.height = 24
msg := fakeKeyMsg("p")
model.handleKey(msg)
select {
case cmd := <-tc.Commands:
if cmd.Command != "previous" {
t.Errorf("expected 'previous' command, got '%s'", cmd.Command)
}
default:
t.Error("expected previous command on transport channel")
}
}
func TestTransportKeyReconnect(t *testing.T) {
tc := NewTransportControl()
model := NewModel(Config{TransportCtrl: tc})
model.width = 80
model.height = 24
msg := fakeKeyMsg("r")
model.handleKey(msg)
select {
case cmd := <-tc.Commands:
if cmd.Command != "reconnect" {
t.Errorf("expected 'reconnect' command, got '%s'", cmd.Command)
}
default:
t.Error("expected reconnect command on transport channel")
}
}
func TestTransportNilSafe(t *testing.T) {
// Transport keys should not panic when transportCtrl is nil
model := NewModel(Config{})
model.width = 80
model.height = 24
// These should not panic
model.handleKey(fakeKeyMsg(" "))
model.handleKey(fakeKeyMsg("n"))
model.handleKey(fakeKeyMsg("p"))
model.handleKey(fakeKeyMsg("r"))
}
func TestPlaybackStateDisplay(t *testing.T) {
tests := []struct {
state string
expected string
}{
{"playing", "\u25b6 Playing"},
{"paused", "\u23f8 Paused"},
{"stopped", "\u23f9 Stopped"},
{"idle", "\u25cf Idle"},
{"reconnecting", "\u21bb Reconnecting..."},
{"unknown", "\u25cf Idle"},
}
for _, tt := range tests {
result := playbackStateDisplay(tt.state)
if result != tt.expected {
t.Errorf("playbackStateDisplay(%q) = %q, expected %q",
tt.state, result, tt.expected)
}
}
}
func TestFormatSampleRate(t *testing.T) {
tests := []struct {
rate int
expected string
}{
{44100, "44.1kHz"},
{48000, "48kHz"},
{96000, "96kHz"},
{192000, "192kHz"},
{88200, "88.2kHz"},
{176400, "176.4kHz"},
{32000, "32kHz"},
{22050, "22.1kHz"},
}
for _, tt := range tests {
result := formatSampleRate(tt.rate)
if result != tt.expected {
t.Errorf("formatSampleRate(%d) = %q, expected %q",
tt.rate, result, tt.expected)
}
}
}
func TestFormatCodecDisplay(t *testing.T) {
tests := []struct {
codec string
sampleRate int
bitDepth int
expected string
}{
{"pcm", 192000, 24, "PCM 192kHz/24bit lossless"},
{"opus", 48000, 16, "OPUS 48kHz/16bit"},
{"flac", 48000, 24, "FLAC 48kHz/24bit lossless"},
{"flac", 44100, 16, "FLAC 44.1kHz/16bit lossless"},
{"aac", 44100, 16, "AAC 44.1kHz/16bit"},
}
for _, tt := range tests {
result := formatCodecDisplay(tt.codec, tt.sampleRate, tt.bitDepth)
if result != tt.expected {
t.Errorf("formatCodecDisplay(%q, %d, %d) = %q, expected %q",
tt.codec, tt.sampleRate, tt.bitDepth, result, tt.expected)
}
}
}
// fakeKeyMsg creates a tea.KeyMsg for testing.
func fakeKeyMsg(key string) tea.KeyMsg {
if key == " " {
return tea.KeyMsg{Type: tea.KeySpace, Runes: []rune(key)}
}
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}
}
// NOTE: TestConcurrentStatusUpdates was removed because Bubble Tea
// guarantees sequential Update() calls - the Model is never accessed
// concurrently in real usage, so testing concurrent access is unrealistic.

View File

@@ -0,0 +1,65 @@
// ABOUTME: TUI initialization and control
// ABOUTME: Wraps bubbletea program for player UI
package ui
import (
tea "github.com/charmbracelet/bubbletea"
)
type VolumeControl struct {
Changes chan VolumeChangeMsg
Quit chan QuitMsg
}
func NewVolumeControl() *VolumeControl {
return &VolumeControl{
Changes: make(chan VolumeChangeMsg, 10),
Quit: make(chan QuitMsg, 1),
}
}
type TransportMsg struct {
Command string // "play", "pause", "toggle", "next", "previous", "reconnect"
}
type TransportControl struct {
Commands chan TransportMsg
}
func NewTransportControl() *TransportControl {
return &TransportControl{
Commands: make(chan TransportMsg, 10),
}
}
// Config bundles everything the TUI needs from main.go at construction.
// Extending by adding fields is intentionally cheap — the alternative was
// growing the Run() / NewModel() positional argument list every time a new
// TUI feature needs a dependency.
type Config struct {
VolumeCtrl *VolumeControl
TransportCtrl *TransportControl
// ConfigPath is where WriteStringKey persists settings like audio_device.
// Empty disables the Save action in modals that would write to disk.
ConfigPath string
// AudioDevice is the device currently driving playback, shown in the
// status line and used to seed the picker's highlighted row.
AudioDevice string
}
func NewModel(cfg Config) Model {
return Model{
volume: 100,
state: "idle",
playbackState: "idle",
volumeCtrl: cfg.VolumeCtrl,
transportCtrl: cfg.TransportCtrl,
configPath: cfg.ConfigPath,
audioDevice: cfg.AudioDevice,
}
}
func Run(cfg Config) (*tea.Program, error) {
p := tea.NewProgram(NewModel(cfg), tea.WithAltScreen())
return p, nil
}

Some files were not shown because too many files have changed in this diff Show More