🎉 live server seems to be working now
This commit is contained in:
53
.claude/settings.local.json
Normal file
53
.claude/settings.local.json
Normal 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
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
bin/*
|
||||||
|
/deploy.sh
|
||||||
63
Dockerfile.cross
Normal file
63
Dockerfile.cross
Normal 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
35
Makefile
Normal 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
114
README.md
Normal 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
752
README.md.1
Normal 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
118
cmd/client/main.go
Normal 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
200
cmd/live-server/main.go
Normal 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
88
cmd/server/main.go
Normal 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
16
dist/systemd/sendspin-client.service
vendored
Normal 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
17
dist/systemd/sendspin-server.service
vendored
Normal 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
162
esphome/speaker.yaml
Normal 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]
|
||||||
46
go.mod
Normal file
46
go.mod
Normal 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
164
go.sum
Normal 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
1924
home-assistant-voice.yaml
Normal file
File diff suppressed because it is too large
Load Diff
9
internal/live/errors.go
Normal file
9
internal/live/errors.go
Normal 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
293
internal/live/http.go
Normal 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
456
internal/live/manager.go
Normal 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
164
internal/live/mdns.go
Normal 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
241
internal/live/mqtt.go
Normal 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
168
internal/live/preset.go
Normal 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
58
internal/live/roster.go
Normal 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
141
internal/live/source.go
Normal 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
19
internal/live/web.go
Normal 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))
|
||||||
|
}
|
||||||
593
internal/live/web/index.html
Normal file
593
internal/live/web/index.html
Normal 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 => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[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
155
internal/pipewire/nodes.go
Normal 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
165
internal/pipewire/output.go
Normal 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
484
internal/pipewire/source.go
Normal 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
211
live-server-spec.md
Normal 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.
|
||||||
752
sendspin-spec.md
Normal file
752
sendspin-spec.md
Normal 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
|
||||||
159
third_party/sendspin-go/.github/workflows/ci.yml
vendored
Normal file
159
third_party/sendspin-go/.github/workflows/ci.yml
vendored
Normal 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 }}
|
||||||
96
third_party/sendspin-go/.github/workflows/conformance.yml
vendored
Normal file
96
third_party/sendspin-go/.github/workflows/conformance.yml
vendored
Normal 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
|
||||||
196
third_party/sendspin-go/.github/workflows/release.yml
vendored
Normal file
196
third_party/sendspin-go/.github/workflows/release.yml
vendored
Normal 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
41
third_party/sendspin-go/.gitignore
vendored
Normal 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
33
third_party/sendspin-go/.golangci.yml
vendored
Normal 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
|
||||||
39
third_party/sendspin-go/.pre-commit-config.yaml
vendored
Normal file
39
third_party/sendspin-go/.pre-commit-config.yaml
vendored
Normal 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
192
third_party/sendspin-go/CHANGELOG.md
vendored
Normal 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
103
third_party/sendspin-go/CLAUDE.md
vendored
Normal 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 7–2 / slot bits 1–0 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
201
third_party/sendspin-go/LICENSE
vendored
Normal 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
227
third_party/sendspin-go/Makefile
vendored
Normal 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
644
third_party/sendspin-go/README.md
vendored
Normal 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)
|
||||||
206
third_party/sendspin-go/cmd/sendspin-server/main.go
vendored
Normal file
206
third_party/sendspin-go/cmd/sendspin-server/main.go
vendored
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
226
third_party/sendspin-go/docs/2026-04-12-layered-architecture-design.md
vendored
Normal file
226
third_party/sendspin-go/docs/2026-04-12-layered-architecture-design.md
vendored
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
# Layered Architecture Design: Receiver + Player Split
|
||||||
|
|
||||||
|
**Date:** 2026-04-12
|
||||||
|
**Status:** Approved
|
||||||
|
**Target version:** v1.2.0
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
The sendspin-go SDK tightly couples audio decoding, clock sync, scheduling, and playback into a single `Player` type. Consumers who want decoded audio bytes without playback (visualizers, DSP pipelines, custom output backends, embedded devices) cannot use the library without initializing an audio device.
|
||||||
|
|
||||||
|
The Sendspin project is standardizing a 3-layer architecture across SDKs (see sendspin-rs):
|
||||||
|
|
||||||
|
1. **Raw connection** — WebSocket transport and message types
|
||||||
|
2. **Data processing** — clock sync, decoding, scheduling
|
||||||
|
3. **Playback** — audio output to hardware
|
||||||
|
|
||||||
|
This design splits `pkg/sendspin.Player` into a `Receiver` (layers 1+2) and a slimmed `Player` (layer 3 + convenience wrapper), with no breaking changes.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Receiver
|
||||||
|
|
||||||
|
The `Receiver` is the core new type. It handles: connect, handshake, clock sync, decode, and schedule. It emits time-stamped, decoded audio buffers via a channel.
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ReceiverConfig struct {
|
||||||
|
ServerAddr string
|
||||||
|
PlayerName string
|
||||||
|
BufferMs int // default: 500
|
||||||
|
DeviceInfo DeviceInfo
|
||||||
|
DecoderFactory func(audio.Format) (decode.Decoder, error) // nil = default
|
||||||
|
OnMetadata func(Metadata)
|
||||||
|
OnStreamStart func(audio.Format)
|
||||||
|
OnStreamEnd func()
|
||||||
|
OnError func(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Receiver struct {
|
||||||
|
client *protocol.Client
|
||||||
|
clockSync *sync.ClockSync // own instance, not global
|
||||||
|
scheduler *Scheduler
|
||||||
|
decoder decode.Decoder
|
||||||
|
// ... context, state
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewReceiver(config ReceiverConfig) (*Receiver, error)
|
||||||
|
func (r *Receiver) Connect() error
|
||||||
|
func (r *Receiver) Output() <-chan audio.Buffer // closed when Receiver.Close() is called
|
||||||
|
func (r *Receiver) ClockSync() *sync.ClockSync
|
||||||
|
func (r *Receiver) Stats() ReceiverStats
|
||||||
|
func (r *Receiver) Close() error
|
||||||
|
```
|
||||||
|
|
||||||
|
The `Receiver` owns all goroutines currently in `Player`: `handleStreamStart`, `handleAudioChunks`, `clockSyncLoop`, `handleStreamClear`, `handleStreamEnd`, `handleServerState`, `handleGroupUpdates`, and `watchConnection`.
|
||||||
|
|
||||||
|
Each `Receiver` creates its own `sync.ClockSync` instance. It never touches the global.
|
||||||
|
|
||||||
|
### Player (Refactored)
|
||||||
|
|
||||||
|
`Player` becomes a thin wrapper composing `Receiver` + `output.Output` + optional hooks. The existing public API is fully preserved.
|
||||||
|
|
||||||
|
```go
|
||||||
|
type PlayerConfig struct {
|
||||||
|
// Existing fields (unchanged)
|
||||||
|
ServerAddr string
|
||||||
|
PlayerName string
|
||||||
|
Volume int
|
||||||
|
BufferMs int
|
||||||
|
DeviceInfo DeviceInfo
|
||||||
|
OnMetadata func(Metadata)
|
||||||
|
OnStateChange func(PlayerState)
|
||||||
|
OnError func(error)
|
||||||
|
|
||||||
|
// New optional fields
|
||||||
|
Output output.Output // nil = auto-select
|
||||||
|
DecoderFactory func(audio.Format) (decode.Decoder, error) // nil = default
|
||||||
|
ProcessCallback func([]int32) // tap before output
|
||||||
|
}
|
||||||
|
|
||||||
|
type Player struct {
|
||||||
|
receiver *Receiver
|
||||||
|
output output.Output
|
||||||
|
config PlayerConfig
|
||||||
|
// ... state, context
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Player.Connect()` internally:
|
||||||
|
|
||||||
|
1. Creates a `Receiver` from its config fields.
|
||||||
|
2. Calls `receiver.Connect()`.
|
||||||
|
3. Sets `sync.SetGlobalClockSync(receiver.ClockSync())` for backward compat.
|
||||||
|
4. Starts a goroutine that reads from `receiver.Output()` and for each buffer:
|
||||||
|
- Calls `config.ProcessCallback(buf.Samples)` if set.
|
||||||
|
- Calls `output.Write(buf.Samples)`.
|
||||||
|
|
||||||
|
Output auto-selection (when `PlayerConfig.Output` is nil) creates a malgo backend to handle all bit depths:
|
||||||
|
|
||||||
|
- All formats -> `output.NewMalgo()` (supports 16/24/32-bit natively)
|
||||||
|
|
||||||
|
`Player` retains: output lifecycle, volume/mute control, `ProcessCallback`, state change notifications.
|
||||||
|
|
||||||
|
### ProcessCallback
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ProcessCallback func([]int32)
|
||||||
|
```
|
||||||
|
|
||||||
|
Called with decoded samples (24-bit range `int32`) before every `output.Write`. Runs on the audio consumption goroutine. Consumers must not block — same constraints as the Rust SDK's callback.
|
||||||
|
|
||||||
|
Use cases: VU meters, visualization overlays, audio monitoring alongside playback.
|
||||||
|
|
||||||
|
For consumers who want audio WITHOUT playback, use `Receiver` directly.
|
||||||
|
|
||||||
|
### ClockSync Changes
|
||||||
|
|
||||||
|
- `Receiver` creates and owns its own `sync.ClockSync` instance.
|
||||||
|
- `Player` still calls `sync.SetGlobalClockSync()` after connect for backward compat.
|
||||||
|
- `sync.SetGlobalClockSync()` and `sync.ServerMicrosNow()` are deprecated (log warning on first use).
|
||||||
|
- Multiple `Receiver` instances can coexist in one process, each with independent clock sync.
|
||||||
|
|
||||||
|
## Consumer Use Cases
|
||||||
|
|
||||||
|
### Visualizer (raw PCM, no playback)
|
||||||
|
|
||||||
|
```go
|
||||||
|
recv, _ := sendspin.NewReceiver(sendspin.ReceiverConfig{
|
||||||
|
ServerAddr: "192.168.1.50:8927",
|
||||||
|
PlayerName: "My Visualizer",
|
||||||
|
})
|
||||||
|
recv.Connect()
|
||||||
|
|
||||||
|
for buf := range recv.Output() {
|
||||||
|
visualizer.ProcessAudio(buf.Samples)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Standard player (unchanged)
|
||||||
|
|
||||||
|
```go
|
||||||
|
player, _ := sendspin.NewPlayer(sendspin.PlayerConfig{
|
||||||
|
ServerAddr: "192.168.1.50:8927",
|
||||||
|
PlayerName: "Living Room",
|
||||||
|
})
|
||||||
|
player.Connect()
|
||||||
|
defer player.Close()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Player with VU meter
|
||||||
|
|
||||||
|
```go
|
||||||
|
player, _ := sendspin.NewPlayer(sendspin.PlayerConfig{
|
||||||
|
ServerAddr: "192.168.1.50:8927",
|
||||||
|
PlayerName: "Living Room",
|
||||||
|
ProcessCallback: func(samples []int32) {
|
||||||
|
vuMeter.Update(samples)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
player.Connect()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom output backend
|
||||||
|
|
||||||
|
```go
|
||||||
|
player, _ := sendspin.NewPlayer(sendspin.PlayerConfig{
|
||||||
|
ServerAddr: "192.168.1.50:8927",
|
||||||
|
PlayerName: "Custom Device",
|
||||||
|
Output: myALSAOutput,
|
||||||
|
})
|
||||||
|
player.Connect()
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
No new packages. `Receiver` lives in `pkg/sendspin` alongside `Player`.
|
||||||
|
|
||||||
|
```
|
||||||
|
pkg/sendspin/
|
||||||
|
receiver.go NEW — Receiver type, all connection/decode/sync goroutines
|
||||||
|
player.go SLIMMED — composes Receiver + Output, volume/mute, ProcessCallback
|
||||||
|
scheduler.go UNCHANGED
|
||||||
|
server.go UNCHANGED
|
||||||
|
source.go UNCHANGED
|
||||||
|
|
||||||
|
pkg/sync/
|
||||||
|
clock.go MINOR — deprecation warnings on global functions
|
||||||
|
|
||||||
|
All other packages UNCHANGED.
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Changes Summary
|
||||||
|
|
||||||
|
### New
|
||||||
|
|
||||||
|
| Symbol | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `sendspin.ReceiverConfig` | Config for data-only client |
|
||||||
|
| `sendspin.NewReceiver()` | Create a Receiver |
|
||||||
|
| `Receiver.Connect()` | Connect and start pipeline |
|
||||||
|
| `Receiver.Output()` | Channel of decoded `audio.Buffer` |
|
||||||
|
| `Receiver.ClockSync()` | Access clock sync instance |
|
||||||
|
| `Receiver.Stats()` | Pipeline statistics |
|
||||||
|
| `Receiver.Close()` | Tear down |
|
||||||
|
| `PlayerConfig.Output` | Inject custom output backend |
|
||||||
|
| `PlayerConfig.DecoderFactory` | Inject custom decoder |
|
||||||
|
| `PlayerConfig.ProcessCallback` | Tap audio before output |
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
|
||||||
|
| Symbol | Replacement |
|
||||||
|
|--------|-------------|
|
||||||
|
| `sync.SetGlobalClockSync()` | Use `Receiver.ClockSync()` |
|
||||||
|
| `sync.ServerMicrosNow()` | Use `receiver.ClockSync().ServerToLocalTime()` |
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Receiver unit tests: connect with mock server, verify buffers arrive on Output channel with correct timestamps.
|
||||||
|
- Player integration tests: verify existing behavior is preserved when refactored to use Receiver internally.
|
||||||
|
- ProcessCallback test: verify callback fires with correct samples before output.Write.
|
||||||
|
- Multi-receiver test: two Receivers to different mock servers, verify independent clock sync.
|
||||||
|
- DecoderFactory test: inject a custom decoder, verify it's used instead of the default.
|
||||||
|
- Output injection test: inject a mock output, verify Write is called with decoded samples.
|
||||||
154
third_party/sendspin-go/docs/CLOCK_SYNC_ANALYSIS.md
vendored
Normal file
154
third_party/sendspin-go/docs/CLOCK_SYNC_ANALYSIS.md
vendored
Normal 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.
|
||||||
163
third_party/sendspin-go/docs/CODE_REVIEW_FIXES.md
vendored
Normal file
163
third_party/sendspin-go/docs/CODE_REVIEW_FIXES.md
vendored
Normal 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.
|
||||||
89
third_party/sendspin-go/docs/FORMAT_NEGOTIATION_FIX.md
vendored
Normal file
89
third_party/sendspin-go/docs/FORMAT_NEGOTIATION_FIX.md
vendored
Normal 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)
|
||||||
112
third_party/sendspin-go/docs/HIRES_TEST_RESULTS.md
vendored
Normal file
112
third_party/sendspin-go/docs/HIRES_TEST_RESULTS.md
vendored
Normal 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**.
|
||||||
147
third_party/sendspin-go/docs/MA_HIRES_ISSUE.md
vendored
Normal file
147
third_party/sendspin-go/docs/MA_HIRES_ISSUE.md
vendored
Normal 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.
|
||||||
131
third_party/sendspin-go/docs/NATIVE_RATE_FIX.md
vendored
Normal file
131
third_party/sendspin-go/docs/NATIVE_RATE_FIX.md
vendored
Normal 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)
|
||||||
430
third_party/sendspin-go/docs/PHASE1_IMPLEMENTATION.md
vendored
Normal file
430
third_party/sendspin-go/docs/PHASE1_IMPLEMENTATION.md
vendored
Normal 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)
|
||||||
349
third_party/sendspin-go/docs/hires-audio-verification.md
vendored
Normal file
349
third_party/sendspin-go/docs/hires-audio-verification.md
vendored
Normal 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** 🧪
|
||||||
460
third_party/sendspin-go/docs/plans/2025-10-23-resonate-player-design.md
vendored
Normal file
460
third_party/sendspin-go/docs/plans/2025-10-23-resonate-player-design.md
vendored
Normal 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/
|
||||||
2939
third_party/sendspin-go/docs/plans/2025-10-23-resonate-player-implementation.md
vendored
Normal file
2939
third_party/sendspin-go/docs/plans/2025-10-23-resonate-player-implementation.md
vendored
Normal file
File diff suppressed because it is too large
Load Diff
530
third_party/sendspin-go/docs/plans/2025-10-25-library-refactor-design.md
vendored
Normal file
530
third_party/sendspin-go/docs/plans/2025-10-25-library-refactor-design.md
vendored
Normal 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
|
||||||
1565
third_party/sendspin-go/docs/plans/2025-10-25-library-refactor-plan.md
vendored
Normal file
1565
third_party/sendspin-go/docs/plans/2025-10-25-library-refactor-plan.md
vendored
Normal file
File diff suppressed because it is too large
Load Diff
440
third_party/sendspin-go/docs/plans/phase1-hires-fixes.md
vendored
Normal file
440
third_party/sendspin-go/docs/plans/phase1-hires-fixes.md
vendored
Normal 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
|
||||||
1464
third_party/sendspin-go/docs/superpowers/plans/2026-04-12-layered-architecture.md
vendored
Normal file
1464
third_party/sendspin-go/docs/superpowers/plans/2026-04-12-layered-architecture.md
vendored
Normal file
File diff suppressed because it is too large
Load Diff
828
third_party/sendspin-go/docs/superpowers/plans/2026-04-14-drop-oto-backend.md
vendored
Normal file
828
third_party/sendspin-go/docs/superpowers/plans/2026-04-14-drop-oto-backend.md
vendored
Normal 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.
|
||||||
750
third_party/sendspin-go/docs/superpowers/plans/2026-04-14-rip-legacy-cli-stack.md
vendored
Normal file
750
third_party/sendspin-go/docs/superpowers/plans/2026-04-14-rip-legacy-cli-stack.md
vendored
Normal 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 207–216) 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 207–216; 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: ~2500–2600 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 207–216):
|
||||||
|
|
||||||
|
```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
|
||||||
|
|
||||||
|
~2500–2600 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.
|
||||||
1372
third_party/sendspin-go/docs/superpowers/plans/2026-04-14-server-initiated-client-discovery.md
vendored
Normal file
1372
third_party/sendspin-go/docs/superpowers/plans/2026-04-14-server-initiated-client-discovery.md
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1278
third_party/sendspin-go/docs/superpowers/plans/2026-04-20-server-config-yaml.md
vendored
Normal file
1278
third_party/sendspin-go/docs/superpowers/plans/2026-04-20-server-config-yaml.md
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1012
third_party/sendspin-go/docs/superpowers/plans/2026-05-01-quickstart-pi.md
vendored
Normal file
1012
third_party/sendspin-go/docs/superpowers/plans/2026-05-01-quickstart-pi.md
vendored
Normal file
File diff suppressed because it is too large
Load Diff
276
third_party/sendspin-go/docs/superpowers/specs/2026-04-20-server-config-yaml-design.md
vendored
Normal file
276
third_party/sendspin-go/docs/superpowers/specs/2026-04-20-server-config-yaml-design.md
vendored
Normal 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.
|
||||||
97
third_party/sendspin-go/docs/superpowers/specs/2026-05-01-quickstart-pi-design.md
vendored
Normal file
97
third_party/sendspin-go/docs/superpowers/specs/2026-05-01-quickstart-pi-design.md
vendored
Normal 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.
|
||||||
179
third_party/sendspin-go/examples/README.md
vendored
Normal file
179
third_party/sendspin-go/examples/README.md
vendored
Normal 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.
|
||||||
74
third_party/sendspin-go/examples/basic-player/README.md
vendored
Normal file
74
third_party/sendspin-go/examples/basic-player/README.md
vendored
Normal 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
|
||||||
88
third_party/sendspin-go/examples/basic-player/main.go
vendored
Normal file
88
third_party/sendspin-go/examples/basic-player/main.go
vendored
Normal 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...")
|
||||||
|
}
|
||||||
101
third_party/sendspin-go/examples/basic-server/README.md
vendored
Normal file
101
third_party/sendspin-go/examples/basic-server/README.md
vendored
Normal 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
|
||||||
77
third_party/sendspin-go/examples/basic-server/main.go
vendored
Normal file
77
third_party/sendspin-go/examples/basic-server/main.go
vendored
Normal 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")
|
||||||
|
}
|
||||||
158
third_party/sendspin-go/examples/custom-source/README.md
vendored
Normal file
158
third_party/sendspin-go/examples/custom-source/README.md
vendored
Normal 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
|
||||||
227
third_party/sendspin-go/examples/custom-source/main.go
vendored
Normal file
227
third_party/sendspin-go/examples/custom-source/main.go
vendored
Normal 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
44
third_party/sendspin-go/go.mod
vendored
Normal 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
150
third_party/sendspin-go/go.sum
vendored
Normal 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
55
third_party/sendspin-go/install-deps.sh
vendored
Normal 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 ./..."
|
||||||
277
third_party/sendspin-go/internal/discovery/mdns.go
vendored
Normal file
277
third_party/sendspin-go/internal/discovery/mdns.go
vendored
Normal 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
|
||||||
|
}
|
||||||
142
third_party/sendspin-go/internal/discovery/mdns_test.go
vendored
Normal file
142
third_party/sendspin-go/internal/discovery/mdns_test.go
vendored
Normal 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:
|
||||||
|
}
|
||||||
|
}
|
||||||
25
third_party/sendspin-go/internal/discovery/txt.go
vendored
Normal file
25
third_party/sendspin-go/internal/discovery/txt.go
vendored
Normal 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
|
||||||
|
}
|
||||||
62
third_party/sendspin-go/internal/discovery/txt_test.go
vendored
Normal file
62
third_party/sendspin-go/internal/discovery/txt_test.go
vendored
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
313
third_party/sendspin-go/internal/server/audio_engine.go
vendored
Normal file
313
third_party/sendspin-go/internal/server/audio_engine.go
vendored
Normal 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
|
||||||
|
}
|
||||||
112
third_party/sendspin-go/internal/server/audio_engine_test.go
vendored
Normal file
112
third_party/sendspin-go/internal/server/audio_engine_test.go
vendored
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
548
third_party/sendspin-go/internal/server/audio_source.go
vendored
Normal file
548
third_party/sendspin-go/internal/server/audio_source.go
vendored
Normal 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()
|
||||||
|
}
|
||||||
147
third_party/sendspin-go/internal/server/flac_encoder.go
vendored
Normal file
147
third_party/sendspin-go/internal/server/flac_encoder.go
vendored
Normal 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()
|
||||||
|
}
|
||||||
123
third_party/sendspin-go/internal/server/flac_encoder_test.go
vendored
Normal file
123
third_party/sendspin-go/internal/server/flac_encoder_test.go
vendored
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
60
third_party/sendspin-go/internal/server/opus_encoder.go
vendored
Normal file
60
third_party/sendspin-go/internal/server/opus_encoder.go
vendored
Normal 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
|
||||||
|
}
|
||||||
261
third_party/sendspin-go/internal/server/opus_encoder_test.go
vendored
Normal file
261
third_party/sendspin-go/internal/server/opus_encoder_test.go
vendored
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
84
third_party/sendspin-go/internal/server/resampler.go
vendored
Normal file
84
third_party/sendspin-go/internal/server/resampler.go
vendored
Normal 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
|
||||||
|
}
|
||||||
260
third_party/sendspin-go/internal/server/resampler_test.go
vendored
Normal file
260
third_party/sendspin-go/internal/server/resampler_test.go
vendored
Normal 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
|
||||||
|
}
|
||||||
605
third_party/sendspin-go/internal/server/server.go
vendored
Normal file
605
third_party/sendspin-go/internal/server/server.go
vendored
Normal 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
|
||||||
|
}
|
||||||
50
third_party/sendspin-go/internal/server/test_tone_source.go
vendored
Normal file
50
third_party/sendspin-go/internal/server/test_tone_source.go
vendored
Normal 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 }
|
||||||
204
third_party/sendspin-go/internal/server/tui.go
vendored
Normal file
204
third_party/sendspin-go/internal/server/tui.go
vendored
Normal 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
|
||||||
|
}
|
||||||
44
third_party/sendspin-go/internal/server/tui_update.go
vendored
Normal file
44
third_party/sendspin-go/internal/server/tui_update.go
vendored
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
208
third_party/sendspin-go/internal/ui/device_picker.go
vendored
Normal file
208
third_party/sendspin-go/internal/ui/device_picker.go
vendored
Normal 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)
|
||||||
|
}
|
||||||
274
third_party/sendspin-go/internal/ui/device_picker_test.go
vendored
Normal file
274
third_party/sendspin-go/internal/ui/device_picker_test.go
vendored
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
115
third_party/sendspin-go/internal/ui/hotkey.go
vendored
Normal file
115
third_party/sendspin-go/internal/ui/hotkey.go
vendored
Normal 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
|
||||||
|
}
|
||||||
123
third_party/sendspin-go/internal/ui/hotkey_test.go
vendored
Normal file
123
third_party/sendspin-go/internal/ui/hotkey_test.go
vendored
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
625
third_party/sendspin-go/internal/ui/model.go
vendored
Normal file
625
third_party/sendspin-go/internal/ui/model.go
vendored
Normal 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}
|
||||||
|
})
|
||||||
|
}
|
||||||
561
third_party/sendspin-go/internal/ui/model_test.go
vendored
Normal file
561
third_party/sendspin-go/internal/ui/model_test.go
vendored
Normal 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.
|
||||||
65
third_party/sendspin-go/internal/ui/tui.go
vendored
Normal file
65
third_party/sendspin-go/internal/ui/tui.go
vendored
Normal 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
Reference in New Issue
Block a user