🎉 live server seems to be working now

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,13 @@
// ABOUTME: Version information for the player
// ABOUTME: Used in device_info sent during handshake; patched at link time via -X
package version
// Version is the player version. Declared as var (not const) so the release
// workflow can inject the tag string with -ldflags "-X .../version.Version=...".
// Go's linker -X flag only patches package-level string vars; constants are
// inlined at every callsite and silently ignored.
var (
Version = "1.6.3"
Product = "Sendspin Go Player"
Manufacturer = "sendspin-go"
)

View File

@@ -0,0 +1,92 @@
// ABOUTME: Tests for version package symbols
// ABOUTME: Ensures version information is properly defined and ldflags-patchable
package version
import (
"testing"
)
func TestVersionDefined(t *testing.T) {
if Version == "" {
t.Error("Version should not be empty")
}
}
func TestProductDefined(t *testing.T) {
if Product == "" {
t.Error("Product should not be empty")
}
}
func TestManufacturerDefined(t *testing.T) {
if Manufacturer == "" {
t.Error("Manufacturer should not be empty")
}
}
func TestVersionFormat(t *testing.T) {
// Version should typically be in format like "0.1.0" or "dev"
if len(Version) == 0 {
t.Error("Version string is empty")
}
// Just verify it's a reasonable string
if len(Version) > 100 {
t.Error("Version string is unreasonably long")
}
}
func TestProductFormat(t *testing.T) {
// Product name should be reasonable length
if len(Product) == 0 {
t.Error("Product name is empty")
}
if len(Product) > 100 {
t.Error("Product name is unreasonably long")
}
}
func TestManufacturerFormat(t *testing.T) {
// Manufacturer should be reasonable
if len(Manufacturer) == 0 {
t.Error("Manufacturer is empty")
}
if len(Manufacturer) > 100 {
t.Error("Manufacturer name is unreasonably long")
}
}
func TestVersionLdflagsPatchable(t *testing.T) {
// Version, Product, and Manufacturer are package-level string vars so the
// release workflow can patch Version via -ldflags -X. Verify the symbols
// are accessible; immutability is intentionally not asserted, since
// patchability requires var declarations.
if Version == "" {
t.Error("Version is empty")
}
if Product == "" {
t.Error("Product is empty")
}
if Manufacturer == "" {
t.Error("Manufacturer is empty")
}
}
func TestVersionNotPlaceholder(t *testing.T) {
// Check for common placeholder values
placeholders := []string{"TODO", "FIXME", "XXX", "placeholder"}
for _, placeholder := range placeholders {
if Version == placeholder {
t.Errorf("Version should not be placeholder value: %s", placeholder)
}
if Product == placeholder {
t.Errorf("Product should not be placeholder value: %s", placeholder)
}
if Manufacturer == placeholder {
t.Errorf("Manufacturer should not be placeholder value: %s", placeholder)
}
}
}

423
third_party/sendspin-go/main.go vendored Normal file
View File

@@ -0,0 +1,423 @@
// ABOUTME: Entry point for Sendspin Protocol player
// ABOUTME: Parses CLI flags and starts the player application
package main
import (
"context"
"flag"
"fmt"
"io"
"log"
"os"
"os/signal"
"runtime"
"syscall"
"time"
"github.com/Sendspin/sendspin-go/internal/discovery"
"github.com/Sendspin/sendspin-go/internal/ui"
"github.com/Sendspin/sendspin-go/internal/version"
"github.com/Sendspin/sendspin-go/pkg/audio/output"
"github.com/Sendspin/sendspin-go/pkg/sendspin"
tea "github.com/charmbracelet/bubbletea"
)
var (
serverAddr = flag.String("server", "", "Manual server address (skip mDNS)")
port = flag.Int("port", 8927, "Port for mDNS advertisement")
name = flag.String("name", "", "Player friendly name (default: hostname-sendspin-player)")
bufferMs = flag.Int("buffer-ms", 150, "Jitter buffer size in milliseconds")
staticDelayMs = flag.Int("static-delay-ms", 0, "Static playback delay in milliseconds for hardware latency compensation")
logFile = flag.String("log-file", "sendspin-player.log", "Log file path")
noTUI = flag.Bool("no-tui", false, "Disable TUI, use streaming logs instead")
streamLogs = flag.Bool("stream-logs", false, "Alias for -no-tui")
productName = flag.String("product-name", "", "Override the product name sent in device_info (default: compiled-in identity)")
manufacturer = flag.String("manufacturer", "", "Override the manufacturer sent in device_info (default: compiled-in identity)")
noReconnect = flag.Bool("no-reconnect", false, "Disable automatic reconnect on connection loss")
daemon = flag.Bool("daemon", false, "Daemon mode: log to stdout only (journalctl-friendly), no TUI, no log file")
preferredCodec = flag.String("preferred-codec", "", "Preferred codec: pcm (default), opus, or flac")
bufferCapacity = flag.Int("buffer-capacity", 1048576, "Buffer capacity in bytes advertised to server (default: 1MB)")
clientID = flag.String("client-id", "", "Override the persisted client_id. When set, the value is written to the config file and reused on subsequent launches.")
configPath = flag.String("config", "", "Path to player.yaml config file. Default search: $SENDSPIN_PLAYER_CONFIG, ~/.config/sendspin/player.yaml, /etc/sendspin/player.yaml.")
audioDevice = flag.String("audio-device", "", "Playback device name (see --list-audio-devices). Empty = miniaudio default.")
listAudio = flag.Bool("list-audio-devices", false, "List available playback devices and exit.")
maxSampleRate = flag.Int("max-sample-rate", 0, "Cap the highest sample rate advertised to the server. 0 = auto-probe the audio device. Set explicitly when the auto-probe is wrong (e.g. Pi3 onboard headphones report 192k but can't actually drain it).")
maxBitDepth = flag.Int("max-bit-depth", 0, "Cap the highest bit depth advertised to the server. 0 = auto-probe. Setting either --max-sample-rate or --max-bit-depth disables auto-probe entirely.")
)
func main() {
flag.Parse()
if *listAudio {
if err := printPlaybackDevices(os.Stdout); err != nil {
fmt.Fprintf(os.Stderr, "failed to list audio devices: %v\n", err)
os.Exit(1)
}
return
}
// Overlay YAML file and SENDSPIN_PLAYER_* env vars onto flag vars for
// anything the user didn't set on the CLI. client_id is handled
// separately below because it has its own resolver and persistence.
// list-audio-devices is already handled above and exits early.
setByUser := map[string]bool{"client-id": true, "config": true, "list-audio-devices": true}
flag.Visit(func(f *flag.Flag) { setByUser[f.Name] = true })
cfg, loadedConfigPath, err := sendspin.LoadPlayerConfig(*configPath)
if err != nil {
log.Fatalf("config: %v", err)
}
if err := sendspin.ApplyEnvAndFile(flag.CommandLine, setByUser, sendspin.PlayerEnvPrefix, cfg.AsStringMap()); err != nil {
log.Fatalf("config overlay: %v", err)
}
// Use TUI if not explicitly disabled; -stream-logs and -daemon both imply -no-tui
useTUI := !(*noTUI || *streamLogs || *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_RDWR|os.O_CREATE|os.O_APPEND, 0600)
if err != nil {
log.Fatalf("error opening log file: %v", err)
}
defer func() { _ = 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))
}
}
playerName := *name
if playerName == "" {
hostname, err := os.Hostname()
if err != nil {
hostname = "unknown"
}
playerName = fmt.Sprintf("%s-sendspin-player", hostname)
}
// Set up sigChan before discovery so the select loop can catch Ctrl+C during browsing
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
if !useTUI {
log.Printf("Starting Sendspin Player: %s (version %s)", playerName, version.Version)
if *daemon {
log.Printf("Daemon mode: logging to stdout only")
}
}
var tuiProg *tea.Program
var volumeCtrl *ui.VolumeControl
var transportCtrl *ui.TransportControl
if useTUI {
volumeCtrl = ui.NewVolumeControl()
transportCtrl = ui.NewTransportControl()
var err error
// Hand the picker a writable config path — fall back to the OS
// default location if nothing was loaded, so selections still persist
// on first-ever launch.
uiConfigPath := loadedConfigPath
if uiConfigPath == "" {
if p, perr := sendspin.DefaultPlayerConfigPath(); perr == nil {
uiConfigPath = p
}
}
tuiProg, err = ui.Run(ui.Config{
VolumeCtrl: volumeCtrl,
TransportCtrl: transportCtrl,
ConfigPath: uiConfigPath,
AudioDevice: *audioDevice,
})
if err != nil {
log.Fatalf("Failed to start TUI: %v", err)
}
go tuiProg.Run()
}
updateTUI := func(msg ui.StatusMsg) {
if tuiProg != nil {
tuiProg.Send(msg)
}
}
var serverAddress string
var disc *discovery.Manager
if *serverAddr == "" {
log.Printf("Searching for servers via mDNS (press Ctrl+C to quit)...")
disc = discovery.NewManager(discovery.Config{
ServiceName: playerName,
Port: *port,
})
disc.Advertise()
disc.Browse()
// Wait for server discovery or shutdown
select {
case server := <-disc.Servers():
serverAddress = fmt.Sprintf("%s:%d", server.Host, server.Port)
log.Printf("Discovered server at %s", serverAddress)
case sig := <-sigChan:
log.Printf("Received %v during discovery, shutting down", sig)
return
}
} else {
serverAddress = *serverAddr
}
deviceProduct := version.Product
if *productName != "" {
deviceProduct = *productName
}
deviceManufacturer := version.Manufacturer
if *manufacturer != "" {
deviceManufacturer = *manufacturer
}
resolvedClientID, err := resolveClientID(*clientID, cfg, loadedConfigPath)
if err != nil {
log.Fatalf("client_id: %v", err)
}
config := sendspin.PlayerConfig{
ServerAddr: serverAddress,
PlayerName: playerName,
Volume: 100,
BufferMs: *bufferMs,
StaticDelayMs: *staticDelayMs,
PreferredCodec: *preferredCodec,
BufferCapacity: *bufferCapacity,
ClientID: resolvedClientID,
AudioDevice: *audioDevice,
MaxSampleRate: *maxSampleRate,
MaxBitDepth: *maxBitDepth,
DeviceInfo: sendspin.DeviceInfo{
ProductName: deviceProduct,
Manufacturer: deviceManufacturer,
SoftwareVersion: version.Version,
},
OnStateChange: func(state sendspin.PlayerState) {
updateTUI(ui.StatusMsg{
Codec: state.Codec,
SampleRate: state.SampleRate,
Channels: state.Channels,
BitDepth: state.BitDepth,
PlaybackState: state.State,
})
connected := state.Connected
serverLabel := serverAddress
if state.State == "reconnecting" {
serverLabel = "reconnecting..."
}
updateTUI(ui.StatusMsg{
Connected: &connected,
ServerName: serverLabel,
})
},
OnMetadata: func(meta sendspin.Metadata) {
updateTUI(ui.StatusMsg{
Title: meta.Title,
Artist: meta.Artist,
Album: meta.Album,
})
},
OnError: func(err error) {
log.Printf("Player error: %v", err)
},
Reconnect: sendspin.ReconnectConfig{
Enabled: !*noReconnect,
},
}
if !*noReconnect && disc != nil {
config.Reconnect.Rediscover = func(ctx context.Context) (string, error) {
disc.Browse()
select {
case server := <-disc.Servers():
addr := fmt.Sprintf("%s:%d", server.Host, server.Port)
log.Printf("Rediscovered server at %s", addr)
return addr, nil
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(5 * time.Second):
return "", fmt.Errorf("rediscover timed out")
}
}
}
player, err := sendspin.NewPlayer(config)
if err != nil {
log.Fatalf("Failed to create player: %v", err)
}
if err := player.Connect(); err != nil {
log.Fatalf("Connection failed: %v", err)
}
log.Printf("Connected to server: %s", serverAddress)
if volumeCtrl != nil {
go handleVolumeControl(player, volumeCtrl)
}
if transportCtrl != nil {
go handleTransportControl(player, transportCtrl)
}
if tuiProg != nil {
go statsUpdateLoop(player, updateTUI)
}
if volumeCtrl != nil {
select {
case <-volumeCtrl.Quit:
log.Printf("Received quit signal from TUI")
case <-sigChan:
log.Printf("Shutdown signal received")
}
} else {
<-sigChan
log.Printf("Shutdown signal received")
}
if err := player.Close(); err != nil {
log.Printf("Error closing player: %v", err)
}
log.Printf("Player stopped")
}
func handleVolumeControl(player *sendspin.Player, volumeCtrl *ui.VolumeControl) {
for {
select {
case vol := <-volumeCtrl.Changes:
log.Printf("Volume change: %d%%, muted=%v", vol.Volume, vol.Muted)
player.SetVolume(vol.Volume)
player.Mute(vol.Muted)
case <-volumeCtrl.Quit:
return
}
}
}
func handleTransportControl(player *sendspin.Player, ctrl *ui.TransportControl) {
for cmd := range ctrl.Commands {
if !player.Status().Connected {
log.Printf("Transport command %q ignored: not connected", cmd.Command)
continue
}
var err error
switch cmd.Command {
case "toggle":
// Toggle sends "pause" if server is playing, "play" otherwise.
// The server decides the actual state; we just request it.
status := player.Status()
if status.State == "playing" {
err = player.SendCommand("pause")
} else {
err = player.SendCommand("play")
}
case "play":
err = player.SendCommand("play")
case "pause":
err = player.SendCommand("pause")
case "next":
err = player.SendCommand("next")
case "previous":
err = player.SendCommand("previous")
case "reconnect":
log.Printf("Manual reconnect requested")
}
if err != nil {
log.Printf("Transport command %q failed: %v", cmd.Command, err)
}
}
}
// printPlaybackDevices enumerates every playback device miniaudio can see
// and prints them in a format a user can copy/paste into --audio-device or
// the audio_device: key in player.yaml. The '[*]' marker identifies the
// device miniaudio considers its current default.
func printPlaybackDevices(w io.Writer) error {
devices, err := output.ListPlaybackDevices()
if err != nil {
return err
}
if len(devices) == 0 {
fmt.Fprintln(w, "No playback devices found.")
return nil
}
fmt.Fprintln(w, "Playback devices:")
for _, d := range devices {
marker := "[ ]"
if d.IsDefault {
marker = "[*]"
}
fmt.Fprintf(w, " %s %q\n", marker, d.Name)
}
fmt.Fprintln(w)
fmt.Fprintln(w, "[*] = current default. Use --audio-device \"<name>\" or set audio_device: in player.yaml.")
fmt.Fprintln(w, "On Linux, miniaudio names look like \"<short>, <description>\"; either the full name or just the short part before the comma will match.")
return nil
}
// resolveClientID chooses the player's client_id and arranges for it to be
// persisted to the config file when a new value is generated or when a CLI
// override differs from the on-disk value. Separated from main() purely for
// readability — this is the only place the three inputs (flag, file, MAC)
// and the write-back path come together.
func resolveClientID(override string, cfg *sendspin.PlayerConfigFile, loadedConfigPath string) (string, error) {
persistPath := loadedConfigPath
if persistPath == "" {
p, err := sendspin.DefaultPlayerConfigPath()
if err != nil {
// No writable config dir. Proceed without write-back; a generated
// UUID will be stable for this session only, and the log line from
// ResolveClientID will show (source: generated) with no "persisted".
log.Printf("client_id: no config path available for persistence: %v", err)
} else {
persistPath = p
}
}
var persist sendspin.PersistFn
if persistPath != "" {
persist = func(id string) error {
return sendspin.WriteStringKey(persistPath, "client_id", id)
}
}
var fromConfig string
if cfg != nil {
fromConfig = cfg.ClientID
}
return sendspin.ResolveClientID(override, fromConfig, persist)
}
func statsUpdateLoop(player *sendspin.Player, updateTUI func(ui.StatusMsg)) {
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for range ticker.C {
stats := player.Stats()
// NumGoroutine is cheap; ReadMemStats removed to avoid stop-the-world pauses
updateTUI(ui.StatusMsg{
Received: stats.Received,
Played: stats.Played,
Dropped: stats.Dropped,
BufferDepth: stats.BufferDepth,
SyncRTT: stats.SyncRTT,
SyncQuality: stats.SyncQuality,
Goroutines: runtime.NumGoroutine(),
})
}
}

View File

@@ -0,0 +1,9 @@
// ABOUTME: Decoder interface definition
// ABOUTME: Common interface for all audio decoders
package decode
// Decoder decodes audio in various formats to PCM int32 samples
type Decoder interface {
Decode(data []byte) ([]int32, error)
Close() error
}

View File

@@ -0,0 +1,14 @@
// ABOUTME: Audio decoder package for multiple codec support
// ABOUTME: Provides Decoder interface and implementations for PCM, Opus, FLAC
// Package decode provides audio decoders for various codecs.
//
// Supports: PCM (16-bit and 24-bit), Opus, FLAC (stub)
//
// All decoders implement the Decoder interface and output int32 samples
// in 24-bit range for consistent hi-res audio processing.
//
// Example:
//
// decoder, err := decode.NewPCM(format)
// samples, err := decoder.Decode(audioData)
package decode

View File

@@ -0,0 +1,185 @@
// ABOUTME: FLAC streaming decoder using io.Pipe + mewkiz/flac
// ABOUTME: Decodes FLAC frames to int32 samples for the playback pipeline
package decode
import (
"bytes"
"fmt"
"io"
"log"
"sync"
"github.com/Sendspin/sendspin-go/pkg/audio"
"github.com/mewkiz/flac"
)
// FLACDecoder decodes streaming FLAC frames to int32 PCM samples. It
// bridges chunk-by-chunk network delivery with mewkiz/flac's io.Reader
// API using an io.Pipe. A background goroutine reads FLAC frames from
// the pipe and pushes decoded samples to an internal channel.
type FLACDecoder struct {
format audio.Format
pipeWriter *io.PipeWriter
sampleCh chan []int32
errCh chan error
closed bool
mu sync.Mutex
}
func NewFLAC(format audio.Format) (Decoder, error) {
if format.Codec != "flac" {
return nil, fmt.Errorf("invalid codec for FLAC decoder: %s", format.Codec)
}
if len(format.CodecHeader) == 0 {
return nil, fmt.Errorf("FLAC decoder requires CodecHeader (STREAMINFO)")
}
pr, pw := io.Pipe()
d := &FLACDecoder{
format: format,
pipeWriter: pw,
sampleCh: make(chan []int32, 16),
errCh: make(chan error, 1),
}
go d.runDecoder(pr, format.CodecHeader)
return d, nil
}
// runDecoder writes the codec header, initializes the FLAC stream, and
// loops calling ParseNext to decode frames. Runs in a background
// goroutine for the lifetime of the decoder.
func (d *FLACDecoder) runDecoder(pr *io.PipeReader, codecHeader []byte) {
defer close(d.sampleCh)
defer pr.Close()
// Create a reader that starts with the codec_header, then reads
// from the pipe for the frame data.
combined := io.MultiReader(bytes.NewReader(codecHeader), pr)
stream, err := flac.New(combined)
if err != nil {
select {
case d.errCh <- fmt.Errorf("flac.New: %w", err):
default:
}
return
}
channels := int(stream.Info.NChannels)
bitDepth := int(stream.Info.BitsPerSample)
for {
frame, err := stream.ParseNext()
if err != nil {
if err == io.EOF || err == io.ErrUnexpectedEOF {
return
}
// Pipe closed = normal shutdown
if err.Error() == "io: read/write on closed pipe" {
return
}
log.Printf("FLAC ParseNext error: %v", err)
return
}
blockSize := int(frame.BlockSize)
samples := make([]int32, blockSize*channels)
idx := 0
for i := 0; i < blockSize; i++ {
for ch := 0; ch < channels; ch++ {
sample := frame.Subframes[ch].Samples[i]
// Convert to 24-bit int32 range (same logic as FLACSource
// in internal/server/audio_source.go)
var converted int32
if bitDepth == 16 {
converted = sample << 8
} else if bitDepth == 24 {
converted = sample
} else {
shift := bitDepth - 24
if shift > 0 {
converted = sample >> shift
} else {
converted = sample << -shift
}
}
samples[idx] = converted
idx++
}
}
d.sampleCh <- samples
}
}
func (d *FLACDecoder) Decode(data []byte) ([]int32, error) {
d.mu.Lock()
if d.closed {
d.mu.Unlock()
return nil, fmt.Errorf("decoder closed")
}
d.mu.Unlock()
// Check for initialization errors from the background goroutine.
select {
case err := <-d.errCh:
return nil, err
default:
}
// Write the chunk data to the pipe. This feeds the background
// goroutine's ParseNext loop.
_, err := d.pipeWriter.Write(data)
if err != nil {
return nil, fmt.Errorf("write to FLAC pipe: %w", err)
}
// Return at most one frame per call. The server guarantees 1 chunk
// = 1 FLAC frame (encoder block size matches ChunkDurationMs), so
// each Decode call should yield exactly one frame's samples.
//
// Draining all queued frames into one return value would tag every
// frame with the current chunk's timestamp — collapsing per-frame
// timing into a single PlayAt, which both breaks multi-room sync
// and hands the playback ring buffer multiples of its capacity in
// one Write (see issue: "ring buffer full, dropped N samples").
//
// If the parsing goroutine has raced ahead and queued more than
// one frame, the extras stay buffered on sampleCh and surface on
// subsequent Decode calls (one per call). The frame may also span
// multiple chunks; in that case the goroutine has not produced
// anything yet and we return (nil, nil) — the receiver skips the
// chunk and the frame surfaces on a later Decode.
select {
case samples, ok := <-d.sampleCh:
if !ok {
return nil, io.EOF
}
return samples, nil
default:
return nil, nil
}
}
func (d *FLACDecoder) Close() error {
d.mu.Lock()
defer d.mu.Unlock()
if d.closed {
return nil
}
d.closed = true
d.pipeWriter.Close()
// Drain remaining samples so the goroutine can exit.
for range d.sampleCh {
}
return nil
}

View File

@@ -0,0 +1,157 @@
// ABOUTME: Integration test for FLAC decoder using real FLAC files
// ABOUTME: Skipped when no FLAC fixture is available
package decode
import (
"io"
"os"
"path/filepath"
"sync"
"testing"
"github.com/Sendspin/sendspin-go/pkg/audio"
"github.com/mewkiz/flac"
)
// findFLACFixture looks for a FLAC test file in known locations.
func findFLACFixture() string {
candidates := []string{
// conformance repo fixture (relative to pkg/audio/decode/)
"../../../conformance/repos/sendspin-cli/tests/fixtures/almost_silent.flac",
"../../../../conformance/repos/sendspin-cli/tests/fixtures/almost_silent.flac",
"../../../conformance/fixtures/almost-silent-5s-48000-2-24.flac",
"../../../../conformance/fixtures/almost-silent-5s-48000-2-24.flac",
}
for _, candidate := range candidates {
abs, err := filepath.Abs(candidate)
if err != nil {
continue
}
if _, err := os.Stat(abs); err == nil {
return abs
}
}
return ""
}
// splitFLACFile reads a FLAC file and returns the codec_header (fLaC +
// all metadata blocks) and the raw frame data (everything after metadata).
func splitFLACFile(path string) (codecHeader []byte, frameData []byte, sampleRate, channels, bitDepth int, totalSamples uint64, err error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, nil, 0, 0, 0, 0, err
}
if len(raw) < 4 || string(raw[:4]) != "fLaC" {
return nil, nil, 0, 0, 0, 0, io.ErrUnexpectedEOF
}
// Parse metadata blocks to find where frame data starts.
offset := 4
for {
if offset+4 > len(raw) {
return nil, nil, 0, 0, 0, 0, io.ErrUnexpectedEOF
}
header := raw[offset : offset+4]
lastBlock := header[0]&0x80 != 0
blockLength := int(header[1])<<16 | int(header[2])<<8 | int(header[3])
offset += 4 + blockLength
if lastBlock {
break
}
}
codecHeader = make([]byte, offset)
copy(codecHeader, raw[:offset])
// Get stream info for verification
f, err := os.Open(path)
if err != nil {
return nil, nil, 0, 0, 0, 0, err
}
defer f.Close()
stream, err := flac.New(f)
if err != nil {
return nil, nil, 0, 0, 0, 0, err
}
return codecHeader, raw[offset:], int(stream.Info.SampleRate), int(stream.Info.NChannels), int(stream.Info.BitsPerSample), stream.Info.NSamples, nil
}
func TestFLACDecoder_RealFile(t *testing.T) {
fixturePath := findFLACFixture()
if fixturePath == "" {
t.Skip("no FLAC fixture found — skipping integration test")
}
codecHeader, frameData, sampleRate, channels, bitDepth, totalSamples, err := splitFLACFile(fixturePath)
if err != nil {
t.Fatalf("splitFLACFile: %v", err)
}
t.Logf("Fixture: %s", filepath.Base(fixturePath))
t.Logf("Format: %dHz %dch %dbit, %d total samples", sampleRate, channels, bitDepth, totalSamples)
t.Logf("Codec header: %d bytes, frame data: %d bytes", len(codecHeader), len(frameData))
format := audio.Format{
Codec: "flac",
SampleRate: sampleRate,
Channels: channels,
BitDepth: bitDepth,
CodecHeader: codecHeader,
}
dec, err := NewFLAC(format)
if err != nil {
t.Fatalf("NewFLAC: %v", err)
}
// The FLACDecoder uses an io.Pipe internally: Decode() writes to the
// pipe then drains decoded samples from a channel. With a full file's
// worth of frame data, the internal sample channel (buffer 16) fills
// before the pipe write completes, causing deadlock in a single
// goroutine. To test the full pipeline we access the unexported fields
// directly: write frame data to the pipe from a goroutine, then
// collect decoded samples from the channel until it closes.
flacDec := dec.(*FLACDecoder)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
_, writeErr := flacDec.pipeWriter.Write(frameData)
if writeErr != nil {
t.Errorf("pipe write: %v", writeErr)
}
// Close the writer so the decoder sees EOF and stops.
flacDec.pipeWriter.Close()
}()
var allSamples []int32
for samples := range flacDec.sampleCh {
allSamples = append(allSamples, samples...)
}
wg.Wait()
if len(allSamples) == 0 {
t.Fatal("expected decoded samples, got empty")
}
expectedSamples := int(totalSamples) * channels
t.Logf("Decoded %d samples (expected ~%d)", len(allSamples), expectedSamples)
// Verify sample count is in the right ballpark.
if len(allSamples) < expectedSamples/2 {
t.Errorf("decoded far fewer samples than expected: %d vs %d", len(allSamples), expectedSamples)
}
// Verify samples aren't all zero (sanity check — the fixture is
// "almost silent" but should have some non-zero values).
nonZero := 0
for _, s := range allSamples {
if s != 0 {
nonZero++
}
}
t.Logf("Non-zero samples: %d / %d", nonZero, len(allSamples))
}

View File

@@ -0,0 +1,128 @@
// ABOUTME: Tests for the FLAC streaming decoder
// ABOUTME: Lifecycle tests — create with header, error without, close cleanly
package decode
import (
"testing"
"github.com/Sendspin/sendspin-go/pkg/audio"
)
// buildMinimalFLACHeader creates a syntactically valid FLAC codec_header
// (fLaC marker + STREAMINFO metadata block) for testing decoder lifecycle.
// The header is valid enough for mewkiz/flac to parse STREAMINFO, though
// no frames follow it.
func buildMinimalFLACHeader(sampleRate, channels, bitDepth, blockSize int) []byte {
header := make([]byte, 0, 42)
// fLaC marker
header = append(header, 'f', 'L', 'a', 'C')
// Metadata block header: last=1 (0x80), type=0 (STREAMINFO), length=34
header = append(header, 0x80, 0x00, 0x00, 34)
// STREAMINFO (34 bytes)
streamInfo := make([]byte, 34)
// min block size (bytes 0-1)
streamInfo[0] = byte(blockSize >> 8)
streamInfo[1] = byte(blockSize)
// max block size (bytes 2-3)
streamInfo[2] = byte(blockSize >> 8)
streamInfo[3] = byte(blockSize)
// min/max frame size (bytes 4-9): 0 = unknown
// sample rate (20 bits) | channels-1 (3 bits) | bps-1 (5 bits) | total samples (36 bits)
// packed into bytes 10-17
packed := uint64(sampleRate)<<44 | uint64(channels-1)<<41 | uint64(bitDepth-1)<<36
for i := 0; i < 8; i++ {
streamInfo[10+i] = byte(packed >> (56 - 8*i))
}
// MD5 (bytes 18-33): zeros
header = append(header, streamInfo...)
return header
}
func TestNewFLAC_RequiresCodecHeader(t *testing.T) {
format := audio.Format{
Codec: "flac",
SampleRate: 48000,
Channels: 2,
BitDepth: 24,
}
_, err := NewFLAC(format)
if err == nil {
t.Error("expected error when CodecHeader is nil")
}
}
func TestNewFLAC_InvalidCodec(t *testing.T) {
format := audio.Format{
Codec: "opus",
SampleRate: 48000,
Channels: 2,
BitDepth: 24,
}
_, err := NewFLAC(format)
if err == nil {
t.Fatal("expected error for invalid codec")
}
}
func TestNewFLAC_ValidHeader(t *testing.T) {
codecHeader := buildMinimalFLACHeader(48000, 2, 24, 4096)
format := audio.Format{
Codec: "flac",
SampleRate: 48000,
Channels: 2,
BitDepth: 24,
CodecHeader: codecHeader,
}
dec, err := NewFLAC(format)
if err != nil {
t.Fatalf("NewFLAC: %v", err)
}
defer dec.Close()
}
func TestFLACDecoder_CloseWithoutDecode(t *testing.T) {
codecHeader := buildMinimalFLACHeader(48000, 2, 24, 4096)
format := audio.Format{
Codec: "flac",
SampleRate: 48000,
Channels: 2,
BitDepth: 24,
CodecHeader: codecHeader,
}
dec, err := NewFLAC(format)
if err != nil {
t.Fatalf("NewFLAC: %v", err)
}
if err := dec.Close(); err != nil {
t.Errorf("Close: %v", err)
}
// Double close should not panic
if err := dec.Close(); err != nil {
t.Errorf("double Close: %v", err)
}
}
func TestFLACDecoder_DecodeAfterClose(t *testing.T) {
codecHeader := buildMinimalFLACHeader(48000, 2, 24, 4096)
format := audio.Format{
Codec: "flac",
SampleRate: 48000,
Channels: 2,
BitDepth: 24,
CodecHeader: codecHeader,
}
dec, err := NewFLAC(format)
if err != nil {
t.Fatalf("NewFLAC: %v", err)
}
dec.Close()
_, err = dec.Decode([]byte{0x00})
if err == nil {
t.Error("expected error after Close")
}
}

View File

@@ -0,0 +1,52 @@
// ABOUTME: Opus audio decoder
// ABOUTME: Decodes Opus audio to int32 samples
package decode
import (
"fmt"
"github.com/Sendspin/sendspin-go/pkg/audio"
"gopkg.in/hraban/opus.v2"
)
type OpusDecoder struct {
decoder *opus.Decoder
format audio.Format
pcm16Buf []int16 // reusable decode buffer to avoid per-frame allocation
}
func NewOpus(format audio.Format) (Decoder, error) {
if format.Codec != "opus" {
return nil, fmt.Errorf("invalid codec for Opus decoder: %s", format.Codec)
}
dec, err := opus.NewDecoder(format.SampleRate, format.Channels)
if err != nil {
return nil, fmt.Errorf("failed to create opus decoder: %w", err)
}
return &OpusDecoder{
decoder: dec,
format: format,
pcm16Buf: make([]int16, 5760*format.Channels),
}, nil
}
func (d *OpusDecoder) Decode(data []byte) ([]int32, error) {
// Reuse pre-allocated int16 buffer for decode (avoids 23KB alloc per frame)
n, err := d.decoder.Decode(data, d.pcm16Buf)
if err != nil {
return nil, fmt.Errorf("opus decode failed: %w", err)
}
actualSamples := n * d.format.Channels
pcm32 := make([]int32, actualSamples)
for i := 0; i < actualSamples; i++ {
pcm32[i] = audio.SampleFromInt16(d.pcm16Buf[i])
}
return pcm32, nil
}
func (d *OpusDecoder) Close() error {
return nil
}

View File

@@ -0,0 +1,110 @@
// ABOUTME: Tests for Opus decoder
// ABOUTME: Tests Opus decoder creation and validation
package decode
import (
"testing"
"github.com/Sendspin/sendspin-go/pkg/audio"
)
func TestNewOpus(t *testing.T) {
format := audio.Format{
Codec: "opus",
SampleRate: 48000,
Channels: 2,
BitDepth: 16,
}
decoder, err := NewOpus(format)
if err != nil {
t.Fatalf("failed to create decoder: %v", err)
}
if decoder == nil {
t.Fatal("expected decoder to be created")
}
}
func TestNewOpus_InvalidCodec(t *testing.T) {
format := audio.Format{
Codec: "pcm",
SampleRate: 48000,
Channels: 2,
BitDepth: 16,
}
decoder, err := NewOpus(format)
if err == nil {
t.Fatal("expected error for invalid codec, got nil")
}
if decoder != nil {
t.Fatal("expected decoder to be nil for invalid codec")
}
expectedError := "invalid codec for Opus decoder: pcm"
if err.Error() != expectedError {
t.Errorf("expected error %q, got %q", expectedError, err.Error())
}
}
func TestNewOpus_MonoChannel(t *testing.T) {
format := audio.Format{
Codec: "opus",
SampleRate: 48000,
Channels: 1,
BitDepth: 16,
}
decoder, err := NewOpus(format)
if err != nil {
t.Fatalf("failed to create mono decoder: %v", err)
}
if decoder == nil {
t.Fatal("expected decoder to be created")
}
}
func TestNewOpus_InvalidSampleRate(t *testing.T) {
// Opus library may reject invalid sample rates
format := audio.Format{
Codec: "opus",
SampleRate: 44100, // Opus typically uses 48000
Channels: 2,
BitDepth: 16,
}
// We expect this might fail at the opus library level
// This test documents the behavior
decoder, err := NewOpus(format)
// Either it succeeds (opus lib is flexible) or fails (opus lib is strict)
// Both are valid outcomes, we just verify proper error handling
if err != nil && decoder != nil {
t.Fatal("if error is returned, decoder must be nil")
}
if err == nil && decoder == nil {
t.Fatal("if no error, decoder must not be nil")
}
}
func TestOpusClose(t *testing.T) {
format := audio.Format{
Codec: "opus",
SampleRate: 48000,
Channels: 2,
BitDepth: 16,
}
decoder, err := NewOpus(format)
if err != nil {
t.Fatalf("failed to create decoder: %v", err)
}
err = decoder.Close()
if err != nil {
t.Errorf("expected Close to succeed, got error: %v", err)
}
}

View File

@@ -0,0 +1,52 @@
// ABOUTME: PCM audio decoder
// ABOUTME: Decodes 16-bit and 24-bit PCM audio to int32 samples
package decode
import (
"encoding/binary"
"fmt"
"github.com/Sendspin/sendspin-go/pkg/audio"
)
type PCMDecoder struct {
bitDepth int
}
func NewPCM(format audio.Format) (Decoder, error) {
if format.Codec != "pcm" {
return nil, fmt.Errorf("invalid codec for PCM decoder: %s", format.Codec)
}
if format.BitDepth != 16 && format.BitDepth != 24 {
return nil, fmt.Errorf("unsupported bit depth: %d (supported: 16, 24)", format.BitDepth)
}
return &PCMDecoder{
bitDepth: format.BitDepth,
}, nil
}
func (d *PCMDecoder) Decode(data []byte) ([]int32, error) {
if d.bitDepth == 24 {
numSamples := len(data) / 3
samples := make([]int32, numSamples)
for i := 0; i < numSamples; i++ {
b := [3]byte{data[i*3], data[i*3+1], data[i*3+2]}
samples[i] = audio.SampleFrom24Bit(b)
}
return samples, nil
} else {
numSamples := len(data) / 2
samples := make([]int32, numSamples)
for i := 0; i < numSamples; i++ {
sample16 := int16(binary.LittleEndian.Uint16(data[i*2:]))
samples[i] = audio.SampleFromInt16(sample16)
}
return samples, nil
}
}
func (d *PCMDecoder) Close() error {
return nil
}

View File

@@ -0,0 +1,171 @@
// ABOUTME: Tests for PCM decoder
// ABOUTME: Tests 16-bit and 24-bit PCM decoding
package decode
import (
"testing"
"github.com/Sendspin/sendspin-go/pkg/audio"
)
func TestNewPCM(t *testing.T) {
format := audio.Format{
Codec: "pcm",
SampleRate: 48000,
Channels: 2,
BitDepth: 16,
}
decoder, err := NewPCM(format)
if err != nil {
t.Fatalf("failed to create decoder: %v", err)
}
if decoder == nil {
t.Fatal("expected decoder to be created")
}
}
func TestPCMDecode16Bit(t *testing.T) {
format := audio.Format{
Codec: "pcm",
SampleRate: 48000,
Channels: 2,
BitDepth: 16,
}
decoder, err := NewPCM(format)
if err != nil {
t.Fatalf("failed to create decoder: %v", err)
}
input := []byte{0x00, 0x01, 0x02, 0x03}
output, err := decoder.Decode(input)
if err != nil {
t.Fatalf("decode failed: %v", err)
}
expectedSamples := len(input) / 2
if len(output) != expectedSamples {
t.Errorf("expected %d samples, got %d", expectedSamples, len(output))
}
// Verify little-endian conversion with 24-bit scaling
// 0x00, 0x01 -> 0x0100 = 256 (16-bit) -> 256<<8 = 65536 (24-bit)
// 0x02, 0x03 -> 0x0302 = 770 (16-bit) -> 770<<8 = 197120 (24-bit)
expected0 := int32(256 << 8)
if output[0] != expected0 {
t.Errorf("expected first sample %d, got %d", expected0, output[0])
}
expected1 := int32(770 << 8)
if output[1] != expected1 {
t.Errorf("expected second sample %d, got %d", expected1, output[1])
}
}
func TestPCMDecode24Bit(t *testing.T) {
format := audio.Format{
Codec: "pcm",
SampleRate: 192000,
Channels: 2,
BitDepth: 24,
}
decoder, err := NewPCM(format)
if err != nil {
t.Fatalf("failed to create decoder: %v", err)
}
input := []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05}
output, err := decoder.Decode(input)
if err != nil {
t.Fatalf("decode failed: %v", err)
}
expectedSamples := len(input) / 3
if len(output) != expectedSamples {
t.Errorf("expected %d samples, got %d", expectedSamples, len(output))
}
// Verify 24-bit little-endian conversion
// 0x00, 0x01, 0x02 -> 0x020100 = 131328
expected0 := int32(0x020100)
if output[0] != expected0 {
t.Errorf("expected first sample %d, got %d", expected0, output[0])
}
// 0x03, 0x04, 0x05 -> 0x050403 = 328707
expected1 := int32(0x050403)
if output[1] != expected1 {
t.Errorf("expected second sample %d, got %d", expected1, output[1])
}
}
func TestNewPCM_InvalidCodec(t *testing.T) {
format := audio.Format{
Codec: "opus",
SampleRate: 48000,
Channels: 2,
BitDepth: 16,
}
decoder, err := NewPCM(format)
if err == nil {
t.Fatal("expected error for invalid codec, got nil")
}
if decoder != nil {
t.Fatal("expected decoder to be nil for invalid codec")
}
expectedError := "invalid codec for PCM decoder: opus"
if err.Error() != expectedError {
t.Errorf("expected error %q, got %q", expectedError, err.Error())
}
}
func TestNewPCM_UnsupportedBitDepth(t *testing.T) {
format := audio.Format{
Codec: "pcm",
SampleRate: 48000,
Channels: 2,
BitDepth: 32,
}
decoder, err := NewPCM(format)
if err == nil {
t.Fatal("expected error for unsupported bit depth, got nil")
}
if decoder != nil {
t.Fatal("expected decoder to be nil for unsupported bit depth")
}
expectedError := "unsupported bit depth: 32 (supported: 16, 24)"
if err.Error() != expectedError {
t.Errorf("expected error %q, got %q", expectedError, err.Error())
}
}
func TestPCMDecode_EmptyInput(t *testing.T) {
format := audio.Format{
Codec: "pcm",
SampleRate: 48000,
Channels: 2,
BitDepth: 16,
}
decoder, err := NewPCM(format)
if err != nil {
t.Fatalf("failed to create decoder: %v", err)
}
output, err := decoder.Decode([]byte{})
if err != nil {
t.Fatalf("decode failed with empty input: %v", err)
}
if len(output) != 0 {
t.Errorf("expected 0 samples from empty input, got %d", len(output))
}
}

View File

@@ -0,0 +1,24 @@
// ABOUTME: Audio fundamentals package providing core types and utilities
// ABOUTME: Defines Format, Buffer types and sample conversion functions
// Package audio provides fundamental audio types and utilities for hi-res audio processing.
//
// This package defines core types used throughout the sendspin library:
// - Format: Describes audio stream format (codec, sample rate, channels, bit depth)
// - Buffer: Represents decoded PCM audio with timestamp information
//
// It also provides utilities for converting between different sample formats:
// - 16-bit ↔ 24-bit conversions
// - int32 ↔ packed byte conversions
//
// Example:
//
// format := audio.Format{
// Codec: "pcm",
// SampleRate: 192000,
// Channels: 2,
// BitDepth: 24,
// }
//
// // Convert 16-bit sample to 24-bit range
// sample24 := audio.SampleFromInt16(sample16)
package audio

View File

@@ -0,0 +1,14 @@
// ABOUTME: Audio encoder package for encoding PCM to various formats
// ABOUTME: Provides Encoder interface and implementations for PCM, Opus
// Package encode provides audio encoders for various codecs.
//
// Supports: PCM (16-bit and 24-bit), Opus
//
// All encoders accept int32 samples in 24-bit range and encode
// to wire format.
//
// Example:
//
// encoder, err := encode.NewPCM(format)
// data, err := encoder.Encode(samples)
package encode

View File

@@ -0,0 +1,9 @@
// ABOUTME: Encoder interface definition
// ABOUTME: Common interface for all audio encoders
package encode
// Encoder encodes PCM int32 samples to various formats
type Encoder interface {
Encode(samples []int32) ([]byte, error)
Close() error
}

View File

@@ -0,0 +1,64 @@
// ABOUTME: Opus audio encoder
// ABOUTME: Encodes int32 samples to Opus bytes
package encode
import (
"fmt"
"github.com/Sendspin/sendspin-go/pkg/audio"
"gopkg.in/hraban/opus.v2"
)
type OpusEncoder struct {
encoder *opus.Encoder
sampleRate int
channels int
frameSize int
pcmBuf []int16 // reusable conversion buffer
outBuf []byte // reusable encode output buffer
}
func NewOpus(format audio.Format) (Encoder, error) {
if format.Codec != "opus" {
return nil, fmt.Errorf("invalid codec for Opus encoder: %s", format.Codec)
}
encoder, err := opus.NewEncoder(format.SampleRate, format.Channels, opus.AppAudio)
if err != nil {
return nil, fmt.Errorf("failed to create opus encoder: %w", err)
}
// Opus frame size depends on sample rate
frameSize := format.SampleRate / 50 // 20ms frame
return &OpusEncoder{
encoder: encoder,
sampleRate: format.SampleRate,
channels: format.Channels,
frameSize: frameSize,
pcmBuf: make([]int16, frameSize*format.Channels),
outBuf: make([]byte, 4000),
}, nil
}
func (e *OpusEncoder) Encode(samples []int32) ([]byte, error) {
if len(samples) > len(e.pcmBuf) {
e.pcmBuf = make([]int16, len(samples))
}
pcm := e.pcmBuf[:len(samples)]
for i, sample := range samples {
pcm[i] = audio.SampleToInt16(sample)
}
n, err := e.encoder.Encode(pcm, e.outBuf)
if err != nil {
return nil, fmt.Errorf("opus encode error: %w", err)
}
return append([]byte(nil), e.outBuf[:n]...), nil
}
func (e *OpusEncoder) Close() error {
return nil
}

View File

@@ -0,0 +1,159 @@
// ABOUTME: Unit tests for Opus encoder
// ABOUTME: Tests Opus encoding functionality
package encode
import (
"testing"
"github.com/Sendspin/sendspin-go/pkg/audio"
)
func TestNewOpus(t *testing.T) {
tests := []struct {
name string
format audio.Format
wantErr bool
errContains string
}{
{
name: "valid Opus 48kHz stereo",
format: audio.Format{
Codec: "opus",
SampleRate: 48000,
Channels: 2,
BitDepth: 16,
},
wantErr: false,
},
{
name: "valid Opus 48kHz mono",
format: audio.Format{
Codec: "opus",
SampleRate: 48000,
Channels: 1,
BitDepth: 16,
},
wantErr: false,
},
{
name: "invalid codec",
format: audio.Format{
Codec: "pcm",
SampleRate: 48000,
Channels: 2,
BitDepth: 16,
},
wantErr: true,
errContains: "invalid codec",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
encoder, err := NewOpus(tt.format)
if tt.wantErr {
if err == nil {
t.Errorf("NewOpus() expected error, got nil")
} else if tt.errContains != "" && !contains(err.Error(), tt.errContains) {
t.Errorf("NewOpus() error = %v, want error containing %v", err, tt.errContains)
}
} else {
if err != nil {
t.Errorf("NewOpus() unexpected error = %v", err)
}
if encoder == nil {
t.Errorf("NewOpus() returned nil encoder")
}
if encoder != nil {
encoder.Close()
}
}
})
}
}
func TestOpusEncoder_Encode(t *testing.T) {
format := audio.Format{
Codec: "opus",
SampleRate: 48000,
Channels: 2,
BitDepth: 16,
}
encoder, err := NewOpus(format)
if err != nil {
t.Fatalf("NewOpus() failed: %v", err)
}
defer encoder.Close()
// 20ms frame at 48kHz = 960 samples per channel
frameSize := 48000 / 50 // 20ms
samples := make([]int32, frameSize*2) // stereo
for i := 0; i < len(samples); i++ {
samples[i] = int32((i % 1000) * 8388) // Simple pattern
}
output, err := encoder.Encode(samples)
if err != nil {
t.Fatalf("Encode() failed: %v", err)
}
if len(output) == 0 {
t.Errorf("Encode() returned empty output")
}
if len(output) > 4000 {
t.Errorf("Encode() output size %d exceeds max Opus packet size 4000", len(output))
}
}
func TestOpusEncoder_EncodeSilence(t *testing.T) {
format := audio.Format{
Codec: "opus",
SampleRate: 48000,
Channels: 2,
BitDepth: 16,
}
encoder, err := NewOpus(format)
if err != nil {
t.Fatalf("NewOpus() failed: %v", err)
}
defer encoder.Close()
// 20ms frame at 48kHz = 960 samples per channel
frameSize := 48000 / 50 // 20ms
samples := make([]int32, frameSize*2) // stereo, all zeros
output, err := encoder.Encode(samples)
if err != nil {
t.Fatalf("Encode() failed: %v", err)
}
// Even silence should produce valid Opus packets
if len(output) == 0 {
t.Errorf("Encode() returned empty output for silence")
}
if len(output) > 4000 {
t.Errorf("Encode() output size %d exceeds max Opus packet size 4000", len(output))
}
}
func TestOpusEncoder_Close(t *testing.T) {
format := audio.Format{
Codec: "opus",
SampleRate: 48000,
Channels: 2,
BitDepth: 16,
}
encoder, err := NewOpus(format)
if err != nil {
t.Fatalf("NewOpus() failed: %v", err)
}
err = encoder.Close()
if err != nil {
t.Errorf("Close() unexpected error = %v", err)
}
}

View File

@@ -0,0 +1,52 @@
// ABOUTME: PCM audio encoder
// ABOUTME: Encodes int32 samples to 16-bit or 24-bit PCM bytes
package encode
import (
"encoding/binary"
"fmt"
"github.com/Sendspin/sendspin-go/pkg/audio"
)
type PCMEncoder struct {
bitDepth int
}
func NewPCM(format audio.Format) (Encoder, error) {
if format.Codec != "pcm" {
return nil, fmt.Errorf("invalid codec for PCM encoder: %s", format.Codec)
}
if format.BitDepth != 16 && format.BitDepth != 24 {
return nil, fmt.Errorf("unsupported bit depth: %d (supported: 16, 24)", format.BitDepth)
}
return &PCMEncoder{
bitDepth: format.BitDepth,
}, nil
}
func (e *PCMEncoder) Encode(samples []int32) ([]byte, error) {
if e.bitDepth == 24 {
output := make([]byte, len(samples)*3)
for i, sample := range samples {
bytes := audio.SampleTo24Bit(sample)
output[i*3] = bytes[0]
output[i*3+1] = bytes[1]
output[i*3+2] = bytes[2]
}
return output, nil
} else {
output := make([]byte, len(samples)*2)
for i, sample := range samples {
sample16 := audio.SampleToInt16(sample)
binary.LittleEndian.PutUint16(output[i*2:], uint16(sample16))
}
return output, nil
}
}
func (e *PCMEncoder) Close() error {
return nil
}

View File

@@ -0,0 +1,201 @@
// ABOUTME: Unit tests for PCM encoder
// ABOUTME: Tests 16-bit and 24-bit PCM encoding
package encode
import (
"encoding/binary"
"testing"
"github.com/Sendspin/sendspin-go/pkg/audio"
)
func TestNewPCM(t *testing.T) {
tests := []struct {
name string
format audio.Format
wantErr bool
errContains string
}{
{
name: "valid 16-bit PCM",
format: audio.Format{
Codec: "pcm",
SampleRate: 48000,
Channels: 2,
BitDepth: 16,
},
wantErr: false,
},
{
name: "valid 24-bit PCM",
format: audio.Format{
Codec: "pcm",
SampleRate: 48000,
Channels: 2,
BitDepth: 24,
},
wantErr: false,
},
{
name: "invalid codec",
format: audio.Format{
Codec: "opus",
SampleRate: 48000,
Channels: 2,
BitDepth: 16,
},
wantErr: true,
errContains: "invalid codec",
},
{
name: "unsupported bit depth",
format: audio.Format{
Codec: "pcm",
SampleRate: 48000,
Channels: 2,
BitDepth: 32,
},
wantErr: true,
errContains: "unsupported bit depth",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
encoder, err := NewPCM(tt.format)
if tt.wantErr {
if err == nil {
t.Errorf("NewPCM() expected error, got nil")
} else if tt.errContains != "" && !contains(err.Error(), tt.errContains) {
t.Errorf("NewPCM() error = %v, want error containing %v", err, tt.errContains)
}
} else {
if err != nil {
t.Errorf("NewPCM() unexpected error = %v", err)
}
if encoder == nil {
t.Errorf("NewPCM() returned nil encoder")
}
}
})
}
}
func TestPCMEncoder_Encode16Bit(t *testing.T) {
format := audio.Format{
Codec: "pcm",
SampleRate: 48000,
Channels: 2,
BitDepth: 16,
}
encoder, err := NewPCM(format)
if err != nil {
t.Fatalf("NewPCM() failed: %v", err)
}
defer encoder.Close()
samples := []int32{
0, // silence
0x7FFF00, // max positive 16-bit (left-justified in 24-bit)
-0x800000, // max negative 16-bit (left-justified in 24-bit)
0x123400, // arbitrary positive value
-0x567800, // arbitrary negative value
}
output, err := encoder.Encode(samples)
if err != nil {
t.Fatalf("Encode() failed: %v", err)
}
expectedSize := len(samples) * 2
if len(output) != expectedSize {
t.Errorf("Encode() output size = %d, want %d", len(output), expectedSize)
}
for i, sample := range samples {
expected := audio.SampleToInt16(sample)
actual := int16(binary.LittleEndian.Uint16(output[i*2:]))
if actual != expected {
t.Errorf("Sample %d: got %d, want %d", i, actual, expected)
}
}
}
func TestPCMEncoder_Encode24Bit(t *testing.T) {
format := audio.Format{
Codec: "pcm",
SampleRate: 48000,
Channels: 2,
BitDepth: 24,
}
encoder, err := NewPCM(format)
if err != nil {
t.Fatalf("NewPCM() failed: %v", err)
}
defer encoder.Close()
samples := []int32{
0, // silence
0x7FFFFF, // max positive 24-bit
-0x800000, // max negative 24-bit
0x123456, // arbitrary positive value
-0x567890, // arbitrary negative value
}
output, err := encoder.Encode(samples)
if err != nil {
t.Fatalf("Encode() failed: %v", err)
}
expectedSize := len(samples) * 3
if len(output) != expectedSize {
t.Errorf("Encode() output size = %d, want %d", len(output), expectedSize)
}
for i, sample := range samples {
expected := audio.SampleTo24Bit(sample)
actual := [3]byte{
output[i*3],
output[i*3+1],
output[i*3+2],
}
if actual != expected {
t.Errorf("Sample %d: got %v, want %v", i, actual, expected)
}
}
}
func TestPCMEncoder_Close(t *testing.T) {
format := audio.Format{
Codec: "pcm",
SampleRate: 48000,
Channels: 2,
BitDepth: 16,
}
encoder, err := NewPCM(format)
if err != nil {
t.Fatalf("NewPCM() failed: %v", err)
}
err = encoder.Close()
if err != nil {
t.Errorf("Close() unexpected error = %v", err)
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
(len(s) > 0 && len(substr) > 0 && indexOf(s, substr) >= 0))
}
func indexOf(s, substr string) int {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return i
}
}
return -1
}

View File

@@ -0,0 +1,13 @@
// ABOUTME: Audio output package for playing audio
// ABOUTME: Provides Output interface with malgo implementation
// Package output provides audio playback interfaces.
//
// Currently supports:
// - malgo (miniaudio): 16/24/32-bit output, format re-initialization supported
//
// Example:
//
// out := output.NewMalgo()
// err := out.Open(192000, 2, 24) // 192kHz, stereo, 24-bit
// err = out.Write(samples)
package output

View File

@@ -0,0 +1,510 @@
// ABOUTME: Malgo-based audio output implementation with 24-bit support
// ABOUTME: Uses miniaudio library via malgo for true hi-res audio playback
package output
import (
"context"
"fmt"
"log"
"runtime"
"sort"
"strings"
"sync"
"time"
"unsafe"
"github.com/Sendspin/sendspin-go/pkg/audio"
"github.com/gen2brain/malgo"
)
// PlaybackDevice describes a playback endpoint discoverable via miniaudio.
// Returned by ListPlaybackDevices and used to select a specific device when
// constructing a Malgo output.
type PlaybackDevice struct {
Name string
IsDefault bool
ID malgo.DeviceID
}
type Malgo struct {
ctx context.Context
cancel context.CancelFunc
malgoCtx *malgo.AllocatedContext
device *malgo.Device
deviceName string // empty = use default
sampleRate int
channels int
bitDepth int
volume int
muted bool
ready bool
// Ring buffer for callback-based playback
ringBuffer *RingBuffer
mu sync.Mutex
}
// RingBuffer provides thread-safe circular buffer for audio samples
type RingBuffer struct {
buffer []int32
readPos int
writePos int
size int
count int // Number of samples currently in buffer
mu sync.Mutex
}
// NewRingBuffer creates a ring buffer with given capacity (in samples)
func NewRingBuffer(capacity int) *RingBuffer {
return &RingBuffer{
buffer: make([]int32, capacity),
size: capacity,
}
}
// Write adds samples to the ring buffer
func (rb *RingBuffer) Write(samples []int32) int {
rb.mu.Lock()
defer rb.mu.Unlock()
written := 0
for i := 0; i < len(samples) && rb.count < rb.size; i++ {
rb.buffer[rb.writePos] = samples[i]
rb.writePos = (rb.writePos + 1) % rb.size
rb.count++
written++
}
return written
}
// Read retrieves samples from the ring buffer
func (rb *RingBuffer) Read(samples []int32) int {
rb.mu.Lock()
defer rb.mu.Unlock()
read := 0
for i := 0; i < len(samples) && rb.count > 0; i++ {
samples[i] = rb.buffer[rb.readPos]
rb.readPos = (rb.readPos + 1) % rb.size
rb.count--
read++
}
// Zero-fill remaining if underrun
for i := read; i < len(samples); i++ {
samples[i] = 0
}
return read
}
// Available returns the number of samples available to read
func (rb *RingBuffer) Available() int {
rb.mu.Lock()
defer rb.mu.Unlock()
return rb.count
}
// Free returns the number of free slots in the buffer
func (rb *RingBuffer) Free() int {
rb.mu.Lock()
defer rb.mu.Unlock()
return rb.size - rb.count
}
// NewMalgo constructs a new malgo-backed audio output. deviceName selects a
// specific playback device by name (as reported by ListPlaybackDevices). An
// empty deviceName lets miniaudio pick the platform default.
func NewMalgo(deviceName string) Output {
ctx, cancel := context.WithCancel(context.Background())
return &Malgo{
ctx: ctx,
cancel: cancel,
deviceName: deviceName,
volume: 100,
muted: false,
}
}
// ListPlaybackDevices enumerates every playback device miniaudio can see.
// It creates a fresh context and tears it down before returning, so it is
// safe to call before any player/device has been initialized.
func ListPlaybackDevices() ([]PlaybackDevice, error) {
ctx, err := malgo.InitContext(nil, malgo.ContextConfig{}, nil)
if err != nil {
return nil, fmt.Errorf("init malgo context: %w", err)
}
defer func() {
_ = ctx.Uninit()
ctx.Free()
}()
infos, err := ctx.Devices(malgo.Playback)
if err != nil {
return nil, fmt.Errorf("enumerate playback devices: %w", err)
}
out := make([]PlaybackDevice, 0, len(infos))
for _, info := range infos {
out = append(out, PlaybackDevice{
Name: info.Name(),
IsDefault: info.IsDefault != 0,
ID: info.ID,
})
}
return out, nil
}
// matchDevice picks a PlaybackDevice from a list based on a requested name.
//
// Empty requested name -> the device with IsDefault set, else the first in
// the list, else nil if the list is empty (caller falls back to whatever
// miniaudio's default-config path does).
//
// Non-empty requested name -> exact Name match first, then short-name match
// (the text before the first ", "). Miniaudio's Linux/ALSA backend builds
// device names from snd_device_name_hint's DESC field, which follows a
// "<card-short>, <stream-description>" convention, so users naturally try
// just the short part. If the short-name match is ambiguous, we error out
// instead of picking one silently.
//
// Fail-loud on no-match: the error lists every available device name, each
// quoted with %q so embedded commas are distinguishable from the list
// separator. Silent fallback to default is the behavior this feature
// exists to correct.
func matchDevice(devices []PlaybackDevice, requested string) (*PlaybackDevice, error) {
if requested == "" {
if len(devices) == 0 {
return nil, nil
}
for i := range devices {
if devices[i].IsDefault {
return &devices[i], nil
}
}
return &devices[0], nil
}
for i := range devices {
if devices[i].Name == requested {
return &devices[i], nil
}
}
var shortMatches []int
for i, d := range devices {
if idx := strings.Index(d.Name, ", "); idx > 0 && d.Name[:idx] == requested {
shortMatches = append(shortMatches, i)
}
}
if len(shortMatches) == 1 {
return &devices[shortMatches[0]], nil
}
if len(devices) == 0 {
return nil, fmt.Errorf("audio device %q not found (no playback devices available)", requested)
}
quoted := make([]string, len(devices))
for i, d := range devices {
quoted[i] = fmt.Sprintf("%q", d.Name)
}
sort.Strings(quoted)
if len(shortMatches) > 1 {
return nil, fmt.Errorf("audio device %q is ambiguous (matches %d devices by short name); use the full quoted name. Available: %s", requested, len(shortMatches), strings.Join(quoted, ", "))
}
return nil, fmt.Errorf("audio device %q not found; available: %s", requested, strings.Join(quoted, ", "))
}
func (m *Malgo) Open(sampleRate, channels, bitDepth int) error {
m.mu.Lock()
defer m.mu.Unlock()
// If already initialized with same format, reuse
if m.device != nil && m.sampleRate == sampleRate && m.channels == channels && m.bitDepth == bitDepth {
log.Printf("Audio output already initialized with same format, reusing device")
return nil
}
// If format changed, reinitialize
if m.device != nil {
log.Printf("Format change detected (%dHz/%dch/%dbit -> %dHz/%dch/%dbit), reinitializing device",
m.sampleRate, m.channels, m.bitDepth, sampleRate, channels, bitDepth)
if err := m.closeDevice(); err != nil {
return fmt.Errorf("failed to close old device: %w", err)
}
}
if m.malgoCtx == nil {
ctx, err := malgo.InitContext(nil, malgo.ContextConfig{}, nil)
if err != nil {
return fmt.Errorf("failed to initialize malgo context: %w", err)
}
m.malgoCtx = ctx
}
var format malgo.FormatType
switch bitDepth {
case 16:
format = malgo.FormatS16
case 24:
format = malgo.FormatS24
case 32:
format = malgo.FormatS32
default:
return fmt.Errorf("unsupported bit depth: %d (supported: 16, 24, 32)", bitDepth)
}
// Create ring buffer (80ms capacity - tuned for Music Assistant)
bufferSamples := (sampleRate * channels * 80) / 1000
m.ringBuffer = NewRingBuffer(bufferSamples)
deviceConfig := malgo.DefaultDeviceConfig(malgo.Playback)
deviceConfig.Playback.Format = format
deviceConfig.Playback.Channels = uint32(channels)
deviceConfig.SampleRate = uint32(sampleRate)
deviceConfig.Alsa.NoMMap = 1
// Pin the period to 20 ms instead of miniaudio's default low-latency
// 10 ms. Several backends (bcm2835 ALSA on Pi, PulseAudio/PipeWire on
// some Intel Smart Sound paths — see mackron/miniaudio#877) silently
// round the requested 10 ms period to a different internal value and
// then stall the audio callback after a few invocations. Asking for
// 20 ms lands inside the safer range used by miniaudio's own backend
// defaults (see CHANGES.md: PulseAudio default raised to 25 ms in
// v0.11.8 to work around PipeWire glitches) and gives the driver
// enough headroom that the negotiated period matches what we asked
// for. ~20 ms of added pipeline latency is invisible inside Sendspin's
// 200+ ms scheduler budget.
deviceConfig.PeriodSizeInMilliseconds = 20
// Resolve the playback device. When m.deviceName is empty, miniaudio's
// enumerated default is picked (and logged so the operator knows what
// they're getting). When non-empty, the device must exist or Open fails
// loudly — silent fallback defeats the point of the knob.
infos, err := m.malgoCtx.Devices(malgo.Playback)
if err != nil {
return fmt.Errorf("enumerate playback devices: %w", err)
}
catalog := make([]PlaybackDevice, 0, len(infos))
for _, info := range infos {
catalog = append(catalog, PlaybackDevice{
Name: info.Name(),
IsDefault: info.IsDefault != 0,
ID: info.ID,
})
}
chosen, err := matchDevice(catalog, m.deviceName)
if err != nil {
return err
}
// Hand miniaudio a pointer to the selected device ID, pinned across the
// cgo call so Go 1.21+'s pointer check accepts it.
//
// Pinning &chosen.ID[0] directly would fail: chosen is an element inside
// a []PlaybackDevice, and the containing heap object also holds the Go
// string Name — whose backing bytes are another Go pointer that cgo's
// recursive scan would find unpinned and reject. Copying the ID bytes
// into a standalone []byte isolates the pointer target: a byte slice's
// backing array contains only bytes (no further Go pointers), so the
// scan finds nothing to complain about.
var pinner runtime.Pinner
defer pinner.Unpin()
chosenLabel := "(miniaudio default)"
if chosen != nil {
idBuf := append([]byte(nil), chosen.ID[:]...)
pinner.Pin(&idBuf[0])
deviceConfig.Playback.DeviceID = unsafe.Pointer(&idBuf[0])
if chosen.IsDefault {
chosenLabel = fmt.Sprintf("%q (default)", chosen.Name)
} else {
chosenLabel = fmt.Sprintf("%q", chosen.Name)
}
}
onSamples := func(pOutputSample, pInputSamples []byte, frameCount uint32) {
m.dataCallback(pOutputSample, frameCount)
}
deviceCallbacks := malgo.DeviceCallbacks{
Data: onSamples,
}
device, err := malgo.InitDevice(m.malgoCtx.Context, deviceConfig, deviceCallbacks)
if err != nil {
return fmt.Errorf("failed to initialize playback device: %w", err)
}
if err := device.Start(); err != nil {
device.Uninit()
return fmt.Errorf("failed to start device: %w", err)
}
m.device = device
m.sampleRate = sampleRate
m.channels = channels
m.bitDepth = bitDepth
m.ready = true
log.Printf("Audio output initialized: device=%s %dHz/%dch/%d-bit period=%dms (malgo/%s)",
chosenLabel, sampleRate, channels, bitDepth, deviceConfig.PeriodSizeInMilliseconds, formatName(format))
return nil
}
// Write queues audio samples for playback.
// Writes in passes if the ring is too small to absorb the whole buffer
// at once, waiting for the audio callback to drain space between passes.
// Buffers larger than the ring (e.g. Music Assistant's ~85 ms PCM chunks
// against a 80 ms ring) succeed as long as the callback keeps draining.
// Returns an error only if no drain progress occurs for maxStallTime,
// which indicates the audio callback itself has stalled.
func (m *Malgo) Write(samples []int32) error {
if !m.ready {
return fmt.Errorf("output not initialized")
}
const (
retryInterval = 1 * time.Millisecond
maxStallTime = 50 * time.Millisecond
)
volumedSamples := applyVolume(samples, m.volume, m.muted)
written := 0
lastProgress := time.Now()
for written < len(volumedSamples) {
n := m.ringBuffer.Write(volumedSamples[written:])
if n > 0 {
written += n
lastProgress = time.Now()
continue
}
// Ring is full this pass. Wait for the audio callback to
// drain. If we go too long with zero progress, the callback
// has likely stalled — drop the remainder rather than block
// the producer indefinitely.
if time.Since(lastProgress) > maxStallTime {
dropped := len(volumedSamples) - written
return fmt.Errorf("ring buffer stalled, dropped %d of %d samples after %v with no drain progress",
dropped, len(volumedSamples), maxStallTime)
}
time.Sleep(retryInterval)
}
return nil
}
// dataCallback is called by malgo to fill the audio output buffer
func (m *Malgo) dataCallback(pOutput []byte, frameCount uint32) {
totalSamples := int(frameCount) * m.channels
samples := make([]int32, totalSamples)
m.ringBuffer.Read(samples)
switch m.bitDepth {
case 16:
m.write16Bit(pOutput, samples)
case 24:
m.write24Bit(pOutput, samples)
case 32:
m.write32Bit(pOutput, samples)
}
}
// write16Bit converts int32 samples to 16-bit output
func (m *Malgo) write16Bit(output []byte, samples []int32) {
for i, sample := range samples {
sample16 := audio.SampleToInt16(sample)
output[i*2] = byte(sample16)
output[i*2+1] = byte(sample16 >> 8)
}
}
// write24Bit converts int32 samples to 24-bit output (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)
}
}
// write32Bit converts int32 samples to 32-bit output
func (m *Malgo) write32Bit(output []byte, samples []int32) {
for i, sample := range samples {
// Left-shift 24-bit value to fill the upper bits of the 32-bit container
sample32 := sample << 8
output[i*4] = byte(sample32)
output[i*4+1] = byte(sample32 >> 8)
output[i*4+2] = byte(sample32 >> 16)
output[i*4+3] = byte(sample32 >> 24)
}
}
func (m *Malgo) Close() error {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.closeDevice(); err != nil {
return err
}
if m.malgoCtx != nil {
if err := m.malgoCtx.Uninit(); err != nil {
log.Printf("Warning: malgo context uninit error: %v", err)
}
m.malgoCtx.Free()
m.malgoCtx = nil
}
m.cancel()
return nil
}
// closeDevice stops and uninitializes the device; caller must hold m.mu.
func (m *Malgo) closeDevice() error {
if m.device != nil {
if err := m.device.Stop(); err != nil {
log.Printf("Warning: device stop error: %v", err)
}
m.device.Uninit()
m.device = nil
m.ready = false
}
return nil
}
func (m *Malgo) SetVolume(volume int) {
if volume < 0 {
volume = 0
}
if volume > 100 {
volume = 100
}
m.volume = volume
log.Printf("Volume set to %d", volume)
}
func (m *Malgo) SetMuted(muted bool) {
m.muted = muted
log.Printf("Muted: %v", muted)
}
func (m *Malgo) GetVolume() int {
return m.volume
}
func (m *Malgo) IsMuted() bool {
return m.muted
}
func formatName(format malgo.FormatType) string {
switch format {
case malgo.FormatS16:
return "S16"
case malgo.FormatS24:
return "S24"
case malgo.FormatS32:
return "S32"
default:
return fmt.Sprintf("Unknown(%d)", format)
}
}

View File

@@ -0,0 +1,232 @@
// ABOUTME: Tests for the pure matchDevice selection logic used by Open
package output
import (
"strings"
"testing"
"github.com/gen2brain/malgo"
)
// newDevice builds a PlaybackDevice with a unique sentinel ID so tests can
// assert the correct entry was returned. The actual ID bytes are opaque to
// miniaudio at this layer; we only check that matchDevice returns the right
// slice element.
func newDevice(name string, isDefault bool, marker byte) PlaybackDevice {
var id malgo.DeviceID
id[0] = marker
return PlaybackDevice{Name: name, IsDefault: isDefault, ID: id}
}
func TestMatchDevice_EmptyRequest(t *testing.T) {
tests := []struct {
name string
devices []PlaybackDevice
wantNil bool
wantName string
wantMarker byte
}{
{
name: "empty catalog returns nil",
devices: nil,
wantNil: true,
},
{
name: "prefers the device flagged IsDefault",
devices: []PlaybackDevice{
newDevice("First", false, 0x01),
newDevice("DefaultSink", true, 0x02),
newDevice("Third", false, 0x03),
},
wantName: "DefaultSink",
wantMarker: 0x02,
},
{
name: "falls back to first device when none flagged default",
devices: []PlaybackDevice{
newDevice("Alpha", false, 0x0A),
newDevice("Beta", false, 0x0B),
},
wantName: "Alpha",
wantMarker: 0x0A,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := matchDevice(tt.devices, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tt.wantNil {
if got != nil {
t.Errorf("expected nil, got %+v", got)
}
return
}
if got == nil {
t.Fatal("expected a device, got nil")
}
if got.Name != tt.wantName {
t.Errorf("name = %q, want %q", got.Name, tt.wantName)
}
if got.ID[0] != tt.wantMarker {
t.Errorf("id[0] = 0x%x, want 0x%x (wrong slice element returned)", got.ID[0], tt.wantMarker)
}
})
}
}
func TestMatchDevice_ExactNameMatch(t *testing.T) {
devices := []PlaybackDevice{
newDevice("HDA Intel PCH: ALC257 Analog", true, 0x10),
newDevice("HDMI 0", false, 0x11),
newDevice("USB Audio Device", false, 0x12),
}
got, err := matchDevice(devices, "USB Audio Device")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got == nil || got.Name != "USB Audio Device" {
t.Errorf("got %+v, want USB Audio Device", got)
}
if got.ID[0] != 0x12 {
t.Errorf("wrong device matched: id[0] = 0x%x, want 0x12", got.ID[0])
}
}
func TestMatchDevice_NoMatchListsAvailable(t *testing.T) {
devices := []PlaybackDevice{
newDevice("Charlie", false, 0x01),
newDevice("Alpha", true, 0x02),
newDevice("Bravo", false, 0x03),
}
got, err := matchDevice(devices, "DoesNotExist")
if got != nil {
t.Errorf("expected nil device, got %+v", got)
}
if err == nil {
t.Fatal("expected an error")
}
msg := err.Error()
if !strings.Contains(msg, `"DoesNotExist"`) {
t.Errorf("error should name the missing device: %q", msg)
}
// Available names must be listed, sorted, so users can copy/paste the right one.
for _, want := range []string{"Alpha", "Bravo", "Charlie"} {
if !strings.Contains(msg, want) {
t.Errorf("error should list %q; got %q", want, msg)
}
}
alphaIdx := strings.Index(msg, "Alpha")
bravoIdx := strings.Index(msg, "Bravo")
charlieIdx := strings.Index(msg, "Charlie")
if !(alphaIdx < bravoIdx && bravoIdx < charlieIdx) {
t.Errorf("available names should be sorted alphabetically; got %q", msg)
}
}
func TestMatchDevice_NoMatchEmptyCatalogGivesDistinctError(t *testing.T) {
got, err := matchDevice(nil, "Anything")
if got != nil {
t.Errorf("expected nil device, got %+v", got)
}
if err == nil {
t.Fatal("expected an error")
}
msg := err.Error()
if !strings.Contains(msg, "no playback devices available") {
t.Errorf("error should distinguish empty-catalog case: %q", msg)
}
}
// TestMatchDevice_ShortNameMatch covers miniaudio's Linux/ALSA naming where
// device.name is "<card-short>, <stream-description>" — users typing just
// the short prefix should match unambiguously when only one device has that
// prefix. Reproduces the HiFiBerry case from the field bug.
func TestMatchDevice_ShortNameMatch(t *testing.T) {
devices := []PlaybackDevice{
newDevice("Default Audio Device", true, 0x01),
newDevice("vc4-hdmi-0, MAI PCM i2s-hifi-0", false, 0x02),
newDevice("vc4-hdmi-1, MAI PCM i2s-hifi-0", false, 0x03),
newDevice("PDP Audio Device, USB Audio", false, 0x04),
}
got, err := matchDevice(devices, "vc4-hdmi-0")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got == nil || got.ID[0] != 0x02 {
t.Errorf("short-name %q should resolve to id 0x02; got %+v", "vc4-hdmi-0", got)
}
got, err = matchDevice(devices, "PDP Audio Device")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got == nil || got.ID[0] != 0x04 {
t.Errorf("short-name %q should resolve to id 0x04; got %+v", "PDP Audio Device", got)
}
}
// TestMatchDevice_ExactNameWinsOverShortName guards the precedence: if a
// device's full name happens to equal someone else's short prefix, the
// exact match takes priority over the short-name search.
func TestMatchDevice_ExactNameWinsOverShortName(t *testing.T) {
devices := []PlaybackDevice{
newDevice("Foo, long description", false, 0x01),
newDevice("Foo", false, 0x02),
}
got, err := matchDevice(devices, "Foo")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got == nil || got.ID[0] != 0x02 {
t.Errorf("exact match should win: expected id 0x02; got %+v", got)
}
}
// TestMatchDevice_ShortNameAmbiguousReturnsError covers the two-HiFiBerry
// case: the same short prefix matches multiple devices. We must not silently
// pick one.
func TestMatchDevice_ShortNameAmbiguousReturnsError(t *testing.T) {
devices := []PlaybackDevice{
newDevice("HiFiBerry, card 0", false, 0x01),
newDevice("HiFiBerry, card 1", false, 0x02),
}
got, err := matchDevice(devices, "HiFiBerry")
if got != nil {
t.Errorf("expected nil on ambiguous short-name match, got %+v", got)
}
if err == nil {
t.Fatal("expected an error on ambiguous short-name match")
}
msg := err.Error()
if !strings.Contains(msg, "ambiguous") {
t.Errorf("error should mention ambiguity: %q", msg)
}
if !strings.Contains(msg, `"HiFiBerry, card 0"`) || !strings.Contains(msg, `"HiFiBerry, card 1"`) {
t.Errorf("ambiguity error should list both candidates quoted with %%q: %q", msg)
}
}
// TestMatchDevice_NoMatchQuotesNames ensures names with embedded commas are
// distinguishable from the list separator in the error output.
func TestMatchDevice_NoMatchQuotesNames(t *testing.T) {
devices := []PlaybackDevice{
newDevice("vc4-hdmi-0, MAI PCM i2s-hifi-0", false, 0x01),
}
_, err := matchDevice(devices, "nonexistent")
if err == nil {
t.Fatal("expected an error")
}
msg := err.Error()
if !strings.Contains(msg, `"vc4-hdmi-0, MAI PCM i2s-hifi-0"`) {
t.Errorf("name should appear quoted in error so embedded comma is unambiguous: %q", msg)
}
}

View File

@@ -0,0 +1,12 @@
// ABOUTME: Audio output interface definition
// ABOUTME: Common interface for audio playback backends
package output
// Output represents an audio output device
type Output interface {
Open(sampleRate, channels, bitDepth int) error
Write(samples []int32) error
Close() error
SetVolume(volume int)
SetMuted(muted bool)
}

View File

@@ -0,0 +1,9 @@
// ABOUTME: Audio output interface tests
// ABOUTME: Verifies Output interface implementation
package output
import "testing"
func TestMalgoImplementsOutput(t *testing.T) {
var _ Output = (*Malgo)(nil)
}

View File

@@ -0,0 +1,110 @@
// ABOUTME: Capability probe for malgo playback devices (rate/bit-depth ceilings)
// ABOUTME: Used by Player to filter advertised SupportedFormats before handshake
package output
import (
"fmt"
"github.com/gen2brain/malgo"
)
// QueryDeviceCapabilities returns the highest sample rate and bit depth the
// named playback device's malgo (miniaudio) backend reports as natively
// supported. deviceName matches the same way ListPlaybackDevices and Open
// accept it; an empty string selects the platform default.
//
// Best-effort. When the backend reports zero native formats — some devices
// don't, especially on cold-start Windows / Pulse — this returns (0, 0, nil)
// so the caller can fall back to "no cap". On Linux/ALSA the answer can also
// be optimistic, because miniaudio reports what the driver claims to accept,
// and ALSA layers software resampling under formats the underlying hardware
// (e.g. bcm2835 onboard headphones) can't actually sustain. The user-facing
// override knob exists exactly for that case.
//
// Does NOT InitDevice. Cheaper than opening the device, but the trade-off is
// that the answer is a best-guess from miniaudio rather than ground truth.
func QueryDeviceCapabilities(deviceName string) (maxSampleRate, maxBitDepth int, err error) {
ctx, err := malgo.InitContext(nil, malgo.ContextConfig{}, nil)
if err != nil {
return 0, 0, fmt.Errorf("init malgo context: %w", err)
}
defer func() {
_ = ctx.Uninit()
ctx.Free()
}()
infos, err := ctx.Devices(malgo.Playback)
if err != nil {
return 0, 0, fmt.Errorf("enumerate playback devices: %w", err)
}
catalog := make([]PlaybackDevice, 0, len(infos))
for _, info := range infos {
catalog = append(catalog, PlaybackDevice{
Name: info.Name(),
IsDefault: info.IsDefault != 0,
ID: info.ID,
})
}
chosen, err := matchDevice(catalog, deviceName)
if err != nil {
return 0, 0, err
}
if chosen == nil {
// No devices at all. Treat as "no cap" — no audio output is going to
// happen anyway, so the caller's handshake will fail for unrelated
// reasons.
return 0, 0, nil
}
detail, err := ctx.DeviceInfo(malgo.Playback, chosen.ID, malgo.Shared)
if err != nil {
return 0, 0, fmt.Errorf("query device info for %q: %w", chosen.Name, err)
}
maxRate, maxDepth := capsFromFormats(detail.Formats)
return maxRate, maxDepth, nil
}
// capsFromFormats walks a DeviceInfo's native-format list and returns the
// highest sample rate and bit depth observed. Formats with unknown bit
// representations (FormatU8, FormatUnknown) are ignored — we'd rather report
// a lower cap than advertise rates only achievable in unsupported formats.
//
// Pure helper so the cgo-bound QueryDeviceCapabilities doesn't need test
// coverage of its own — capsFromFormats covers the interesting logic.
func capsFromFormats(formats []malgo.DataFormat) (maxSampleRate, maxBitDepth int) {
for _, f := range formats {
bits := formatBits(f.Format)
if bits == 0 {
continue
}
if int(f.SampleRate) > maxSampleRate {
maxSampleRate = int(f.SampleRate)
}
if bits > maxBitDepth {
maxBitDepth = bits
}
}
return maxSampleRate, maxBitDepth
}
// formatBits returns the linear bit count for a malgo FormatType.
// 0 means unknown/unsupported and the caller should ignore the entry.
func formatBits(f malgo.FormatType) int {
switch f {
case malgo.FormatS16:
return 16
case malgo.FormatS24:
return 24
case malgo.FormatS32:
return 32
case malgo.FormatF32:
// 32-bit float carries the same dynamic range as S32 for our
// purposes — both clear our 24-bit advertised ceiling.
return 32
default:
return 0
}
}

View File

@@ -0,0 +1,90 @@
// ABOUTME: Tests for the pure capsFromFormats / formatBits helpers
// ABOUTME: cgo-bound QueryDeviceCapabilities is not exercised here
package output
import (
"testing"
"github.com/gen2brain/malgo"
)
func TestFormatBits(t *testing.T) {
tests := []struct {
format malgo.FormatType
want int
}{
{malgo.FormatS16, 16},
{malgo.FormatS24, 24},
{malgo.FormatS32, 32},
{malgo.FormatF32, 32},
{malgo.FormatUnknown, 0},
{malgo.FormatU8, 0}, // we don't currently advertise 8-bit formats
}
for _, tt := range tests {
if got := formatBits(tt.format); got != tt.want {
t.Errorf("formatBits(%v) = %d, want %d", tt.format, got, tt.want)
}
}
}
func TestCapsFromFormats_Empty(t *testing.T) {
rate, depth := capsFromFormats(nil)
if rate != 0 || depth != 0 {
t.Errorf("expected (0, 0) for empty input, got (%d, %d)", rate, depth)
}
}
func TestCapsFromFormats_TakesMaxAcrossEntries(t *testing.T) {
// Mixed-rate / mixed-depth list. Rate and depth caps come from
// different entries — neither field's max needs to come from the same row.
formats := []malgo.DataFormat{
{Format: malgo.FormatS16, Channels: 2, SampleRate: 192000},
{Format: malgo.FormatS24, Channels: 2, SampleRate: 48000},
}
rate, depth := capsFromFormats(formats)
if rate != 192000 {
t.Errorf("expected max rate 192000, got %d", rate)
}
if depth != 24 {
t.Errorf("expected max depth 24, got %d", depth)
}
}
func TestCapsFromFormats_IgnoresUnknownFormat(t *testing.T) {
// An entry with an unknown FormatType must not contribute to either cap,
// even if its SampleRate is huge — we'd be advertising a rate we couldn't
// actually drive in any of our supported formats.
formats := []malgo.DataFormat{
{Format: malgo.FormatUnknown, Channels: 2, SampleRate: 384000},
{Format: malgo.FormatS16, Channels: 2, SampleRate: 48000},
}
rate, depth := capsFromFormats(formats)
if rate != 48000 {
t.Errorf("expected unknown-format entry to be ignored; got rate %d", rate)
}
if depth != 16 {
t.Errorf("expected depth 16, got %d", depth)
}
}
func TestCapsFromFormats_AllUnknownReturnsZero(t *testing.T) {
formats := []malgo.DataFormat{
{Format: malgo.FormatUnknown, Channels: 2, SampleRate: 192000},
{Format: malgo.FormatU8, Channels: 2, SampleRate: 48000},
}
rate, depth := capsFromFormats(formats)
if rate != 0 || depth != 0 {
t.Errorf("expected (0, 0) when no entry has a known bit depth, got (%d, %d)", rate, depth)
}
}
func TestCapsFromFormats_F32MapsToThirtyTwo(t *testing.T) {
formats := []malgo.DataFormat{
{Format: malgo.FormatF32, Channels: 2, SampleRate: 96000},
}
_, depth := capsFromFormats(formats)
if depth != 32 {
t.Errorf("FormatF32 should map to 32 bits; got %d", depth)
}
}

View File

@@ -0,0 +1,34 @@
// 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
}

View File

@@ -0,0 +1,70 @@
// ABOUTME: Tests for shared volume/mute helpers in pkg/audio/output
// ABOUTME: Covers multiplier table, half-scale, mute, and 24-bit clamping
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}
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) {
// Inputs outside the 24-bit range must clamp, not overflow.
overMax := int32(audio.Max24Bit + 1)
underMin := int32(audio.Min24Bit - 1)
result := applyVolume([]int32{overMax, underMin}, 100, false)
if result[0] != audio.Max24Bit {
t.Errorf("max clamp: expected %d, got %d", audio.Max24Bit, result[0])
}
if result[1] != audio.Min24Bit {
t.Errorf("min clamp: expected %d, got %d", audio.Min24Bit, result[1])
}
}

View File

@@ -0,0 +1,83 @@
// ABOUTME: Linear resampler for converting between audio sample rates
// ABOUTME: Uses linear interpolation to convert interleaved audio samples
package audio
// 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 input samples to output sample rate using linear interpolation.
// input and output are interleaved; returns the number of output samples written.
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 {
inputPos := r.position
inputIdx := int(inputPos)
if inputIdx >= inputFrames-1 {
break
}
frac := inputPos - float64(inputIdx)
for ch := 0; ch < r.channels; ch++ {
sample1 := input[inputIdx*r.channels+ch]
sample2 := input[(inputIdx+1)*r.channels+ch]
interpolated := float64(sample1)*(1.0-frac) + float64(sample2)*frac
output[outIdx*r.channels+ch] = int32(interpolated)
}
outIdx++
r.position += r.ratio
}
// Reset position for next chunk, keeping fractional part
r.position -= float64(int(r.position))
return outIdx * r.channels
}
func (r *Resampler) Reset() {
r.position = 0.0
for i := range r.lastSample {
r.lastSample[i] = 0
}
}
func (r *Resampler) OutputSamplesNeeded(inputSamples int) int {
inputFrames := inputSamples / r.channels
outputFrames := int(float64(inputFrames) / r.ratio)
return outputFrames * r.channels
}
func (r *Resampler) InputSamplesNeeded(outputSamples int) int {
outputFrames := outputSamples / r.channels
inputFrames := int(float64(outputFrames) * r.ratio)
return inputFrames * r.channels
}

View File

@@ -0,0 +1,57 @@
// ABOUTME: Audio type definitions
// ABOUTME: Defines audio formats and decoded buffers
package audio
import "time"
const (
// 24-bit audio range constants
Max24Bit = 8388607 // 2^23 - 1
Min24Bit = -8388608 // -2^23
)
// Format describes audio stream format
type Format struct {
Codec string
SampleRate int
Channels int
BitDepth int
CodecHeader []byte // For FLAC, Opus, etc.
}
// Buffer represents decoded PCM audio
type Buffer struct {
Timestamp int64 // Server timestamp (microseconds)
PlayAt time.Time // Local play time
Samples []int32 // PCM samples (int32 to support both 16-bit and 24-bit)
Format Format
}
// SampleToInt16 converts int32 sample to int16 (for 16-bit playback)
func SampleToInt16(sample int32) int16 {
return int16(sample >> 8)
}
// SampleFromInt16 converts int16 sample to int32 (left-justified in 24-bit)
func SampleFromInt16(sample int16) int32 {
return int32(sample) << 8
}
// SampleTo24Bit converts int32 to 24-bit packed bytes (little-endian)
func SampleTo24Bit(sample int32) [3]byte {
return [3]byte{
byte(sample),
byte(sample >> 8),
byte(sample >> 16),
}
}
// SampleFrom24Bit converts 24-bit packed bytes to int32 (little-endian)
func SampleFrom24Bit(b [3]byte) int32 {
val := int32(b[0]) | int32(b[1])<<8 | int32(b[2])<<16
// Sign-extend from 24-bit to 32-bit
if val&0x800000 != 0 {
val |= ^0xFFFFFF
}
return val
}

View File

@@ -0,0 +1,126 @@
// ABOUTME: Tests for audio types
// ABOUTME: Tests sample conversion functions
package audio
import "testing"
func TestSampleFromInt16(t *testing.T) {
tests := []struct {
name string
input int16
expected int32
}{
{"zero", 0, 0},
{"positive", 100, 100 << 8},
{"negative", -100, -100 << 8},
{"max", 32767, 32767 << 8},
{"min", -32768, -32768 << 8},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := SampleFromInt16(tt.input)
if result != tt.expected {
t.Errorf("expected %d, got %d", tt.expected, result)
}
})
}
}
func TestSampleToInt16(t *testing.T) {
tests := []struct {
name string
input int32
expected int16
}{
{"zero", 0, 0},
{"positive", 100 << 8, 100},
{"negative", -100 << 8, -100},
{"24bit positive", 1000000, 3906}, // 1000000 >> 8 = 3906
{"24bit negative", -1000000, -3907},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := SampleToInt16(tt.input)
if result != tt.expected {
t.Errorf("expected %d, got %d", tt.expected, result)
}
})
}
}
func TestSampleTo24Bit(t *testing.T) {
tests := []struct {
name string
input int32
expected [3]byte
}{
{"zero", 0, [3]byte{0, 0, 0}},
{"positive", 0x123456, [3]byte{0x56, 0x34, 0x12}},
{"negative", -256, [3]byte{0x00, 0xFF, 0xFF}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := SampleTo24Bit(tt.input)
if result != tt.expected {
t.Errorf("expected %v, got %v", tt.expected, result)
}
})
}
}
func TestSampleFrom24Bit(t *testing.T) {
tests := []struct {
name string
input [3]byte
expected int32
}{
{"zero", [3]byte{0, 0, 0}, 0},
{"positive", [3]byte{0x56, 0x34, 0x12}, 0x123456},
{"negative", [3]byte{0x00, 0xFF, 0xFF}, -256},
{"max positive", [3]byte{0xFF, 0xFF, 0x7F}, Max24Bit},
{"max negative", [3]byte{0x00, 0x00, 0x80}, Min24Bit},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := SampleFrom24Bit(tt.input)
if result != tt.expected {
t.Errorf("expected %d, got %d", tt.expected, result)
}
})
}
}
func TestRoundTrip16Bit(t *testing.T) {
// Test that 16-bit samples survive round-trip conversion
samples := []int16{0, 100, -100, 1000, -1000, 32767, -32768}
for _, original := range samples {
sample32 := SampleFromInt16(original)
result := SampleToInt16(sample32)
if result != original {
t.Errorf("round-trip failed: %d -> %d -> %d", original, sample32, result)
}
}
}
func TestRoundTrip24Bit(t *testing.T) {
// Test that 24-bit samples survive round-trip conversion
samples := []int32{0, 100000, -100000, Max24Bit, Min24Bit}
for _, original := range samples {
bytes := SampleTo24Bit(original)
result := SampleFrom24Bit(bytes)
// Mask to 24-bit for comparison
expected := original & 0xFFFFFF
if expected&0x800000 != 0 {
expected |= ^0xFFFFFF
}
if result != expected {
t.Errorf("round-trip failed: %d -> %v -> %d (expected %d)", original, bytes, result, expected)
}
}
}

View File

@@ -0,0 +1,14 @@
// ABOUTME: mDNS service discovery package
// ABOUTME: Discover and advertise Resonate servers on local network
// Package discovery provides mDNS service discovery for Resonate servers.
//
// Allows discovering servers on the local network and advertising
// server availability.
//
// Example:
//
// services, err := discovery.Discover(5 * time.Second)
// for _, svc := range services {
// fmt.Printf("Found: %s at %s:%d\n", svc.Name, svc.Address, svc.Port)
// }
package discovery

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