From d72e439fd95762b63585c70a82be472a586a8de0 Mon Sep 17 00:00:00 2001 From: Stanislav Fifik Date: Thu, 14 May 2026 14:29:57 +0200 Subject: [PATCH] :tada: live server seems to be working now --- .claude/settings.local.json | 53 + .gitignore | 2 + Dockerfile.cross | 63 + Makefile | 35 + README.md | 114 + README.md.1 | 752 +++++ cmd/client/main.go | 118 + cmd/live-server/main.go | 200 ++ cmd/server/main.go | 88 + dist/systemd/sendspin-client.service | 16 + dist/systemd/sendspin-server.service | 17 + esphome/speaker.yaml | 162 + file.wav | Bin 0 -> 1966080 bytes go.mod | 46 + go.sum | 164 + home-assistant-voice.yaml | 1924 +++++++++++ internal/live/errors.go | 9 + internal/live/http.go | 293 ++ internal/live/manager.go | 456 +++ internal/live/mdns.go | 164 + internal/live/mqtt.go | 241 ++ internal/live/preset.go | 168 + internal/live/roster.go | 58 + internal/live/source.go | 141 + internal/live/web.go | 19 + internal/live/web/index.html | 593 ++++ internal/pipewire/nodes.go | 155 + internal/pipewire/output.go | 165 + internal/pipewire/source.go | 484 +++ live-server-spec.md | 211 ++ out.raw | Bin 0 -> 3080192 bytes sendspin-spec.md | 752 +++++ .../sendspin-go/.github/workflows/ci.yml | 159 + .../.github/workflows/conformance.yml | 96 + .../sendspin-go/.github/workflows/release.yml | 196 ++ third_party/sendspin-go/.gitignore | 41 + third_party/sendspin-go/.golangci.yml | 33 + .../sendspin-go/.pre-commit-config.yaml | 39 + third_party/sendspin-go/CHANGELOG.md | 192 ++ third_party/sendspin-go/CLAUDE.md | 103 + third_party/sendspin-go/LICENSE | 201 ++ third_party/sendspin-go/Makefile | 227 ++ third_party/sendspin-go/README.md | 644 ++++ .../sendspin-go/cmd/sendspin-server/main.go | 206 ++ .../2026-04-12-layered-architecture-design.md | 226 ++ .../sendspin-go/docs/CLOCK_SYNC_ANALYSIS.md | 154 + .../sendspin-go/docs/CODE_REVIEW_FIXES.md | 163 + .../docs/FORMAT_NEGOTIATION_FIX.md | 89 + .../sendspin-go/docs/HIRES_TEST_RESULTS.md | 112 + .../sendspin-go/docs/MA_HIRES_ISSUE.md | 147 + .../sendspin-go/docs/NATIVE_RATE_FIX.md | 131 + .../sendspin-go/docs/PHASE1_IMPLEMENTATION.md | 430 +++ .../docs/hires-audio-verification.md | 349 ++ .../2025-10-23-resonate-player-design.md | 460 +++ ...25-10-23-resonate-player-implementation.md | 2939 +++++++++++++++++ .../2025-10-25-library-refactor-design.md | 530 +++ .../plans/2025-10-25-library-refactor-plan.md | 1565 +++++++++ .../docs/plans/phase1-hires-fixes.md | 440 +++ .../plans/2026-04-12-layered-architecture.md | 1464 ++++++++ .../plans/2026-04-14-drop-oto-backend.md | 828 +++++ .../plans/2026-04-14-rip-legacy-cli-stack.md | 750 +++++ ...04-14-server-initiated-client-discovery.md | 1372 ++++++++ .../plans/2026-04-20-server-config-yaml.md | 1278 +++++++ .../plans/2026-05-01-quickstart-pi.md | 1012 ++++++ .../2026-04-20-server-config-yaml-design.md | 276 ++ .../specs/2026-05-01-quickstart-pi-design.md | 97 + third_party/sendspin-go/examples/README.md | 179 + .../examples/basic-player/README.md | 74 + .../sendspin-go/examples/basic-player/main.go | 88 + .../examples/basic-server/README.md | 101 + .../sendspin-go/examples/basic-server/main.go | 77 + .../examples/custom-source/README.md | 158 + .../examples/custom-source/main.go | 227 ++ third_party/sendspin-go/go.mod | 44 + third_party/sendspin-go/go.sum | 150 + third_party/sendspin-go/install-deps.sh | 55 + .../sendspin-go/internal/discovery/mdns.go | 277 ++ .../internal/discovery/mdns_test.go | 142 + .../sendspin-go/internal/discovery/txt.go | 25 + .../internal/discovery/txt_test.go | 62 + .../internal/server/audio_engine.go | 313 ++ .../internal/server/audio_engine_test.go | 112 + .../internal/server/audio_source.go | 548 +++ .../internal/server/flac_encoder.go | 147 + .../internal/server/flac_encoder_test.go | 123 + .../internal/server/opus_encoder.go | 60 + .../internal/server/opus_encoder_test.go | 261 ++ .../sendspin-go/internal/server/resampler.go | 84 + .../internal/server/resampler_test.go | 260 ++ .../sendspin-go/internal/server/server.go | 605 ++++ .../internal/server/test_tone_source.go | 50 + .../sendspin-go/internal/server/tui.go | 204 ++ .../sendspin-go/internal/server/tui_update.go | 44 + .../sendspin-go/internal/ui/device_picker.go | 208 ++ .../internal/ui/device_picker_test.go | 274 ++ third_party/sendspin-go/internal/ui/hotkey.go | 115 + .../sendspin-go/internal/ui/hotkey_test.go | 123 + third_party/sendspin-go/internal/ui/model.go | 625 ++++ .../sendspin-go/internal/ui/model_test.go | 561 ++++ third_party/sendspin-go/internal/ui/tui.go | 65 + .../sendspin-go/internal/version/version.go | 13 + .../internal/version/version_test.go | 92 + third_party/sendspin-go/main.go | 423 +++ .../sendspin-go/pkg/audio/decode/decoder.go | 9 + .../sendspin-go/pkg/audio/decode/doc.go | 14 + .../sendspin-go/pkg/audio/decode/flac.go | 185 ++ .../pkg/audio/decode/flac_integration_test.go | 157 + .../sendspin-go/pkg/audio/decode/flac_test.go | 128 + .../sendspin-go/pkg/audio/decode/opus.go | 52 + .../sendspin-go/pkg/audio/decode/opus_test.go | 110 + .../sendspin-go/pkg/audio/decode/pcm.go | 52 + .../sendspin-go/pkg/audio/decode/pcm_test.go | 171 + third_party/sendspin-go/pkg/audio/doc.go | 24 + .../sendspin-go/pkg/audio/encode/doc.go | 14 + .../sendspin-go/pkg/audio/encode/encoder.go | 9 + .../sendspin-go/pkg/audio/encode/opus.go | 64 + .../sendspin-go/pkg/audio/encode/opus_test.go | 159 + .../sendspin-go/pkg/audio/encode/pcm.go | 52 + .../sendspin-go/pkg/audio/encode/pcm_test.go | 201 ++ .../sendspin-go/pkg/audio/output/doc.go | 13 + .../sendspin-go/pkg/audio/output/malgo.go | 510 +++ .../pkg/audio/output/malgo_test.go | 232 ++ .../sendspin-go/pkg/audio/output/output.go | 12 + .../pkg/audio/output/output_test.go | 9 + .../sendspin-go/pkg/audio/output/query.go | 110 + .../pkg/audio/output/query_test.go | 90 + .../sendspin-go/pkg/audio/output/volume.go | 34 + .../pkg/audio/output/volume_test.go | 70 + third_party/sendspin-go/pkg/audio/resample.go | 83 + third_party/sendspin-go/pkg/audio/types.go | 57 + .../sendspin-go/pkg/audio/types_test.go | 126 + third_party/sendspin-go/pkg/discovery/doc.go | 14 + third_party/sendspin-go/pkg/discovery/mdns.go | 169 + .../sendspin-go/pkg/discovery/mdns_test.go | 196 ++ .../sendspin-go/pkg/protocol/client.go | 642 ++++ .../sendspin-go/pkg/protocol/client_test.go | 773 +++++ third_party/sendspin-go/pkg/protocol/doc.go | 12 + .../sendspin-go/pkg/protocol/messages.go | 330 ++ .../sendspin-go/pkg/protocol/messages_test.go | 336 ++ .../sendspin-go/pkg/protocol/server_conn.go | 145 + .../pkg/protocol/server_conn_test.go | 146 + .../pkg/sendspin/buffer_tracker.go | 115 + .../pkg/sendspin/buffer_tracker_test.go | 157 + .../sendspin-go/pkg/sendspin/client_dialer.go | 158 + .../pkg/sendspin/client_dialer_test.go | 165 + .../client_discovery_integration_test.go | 139 + .../sendspin-go/pkg/sendspin/client_id.go | 130 + .../pkg/sendspin/client_id_test.go | 244 ++ .../sendspin-go/pkg/sendspin/config.go | 395 +++ .../pkg/sendspin/config_server_test.go | 119 + .../sendspin-go/pkg/sendspin/config_test.go | 397 +++ third_party/sendspin-go/pkg/sendspin/doc.go | 30 + third_party/sendspin-go/pkg/sendspin/group.go | 300 ++ .../sendspin-go/pkg/sendspin/group_role.go | 163 + .../pkg/sendspin/group_role_test.go | 147 + .../sendspin-go/pkg/sendspin/group_test.go | 327 ++ .../sendspin-go/pkg/sendspin/player.go | 583 ++++ .../sendspin-go/pkg/sendspin/player_test.go | 259 ++ .../sendspin-go/pkg/sendspin/receiver.go | 823 +++++ .../sendspin-go/pkg/sendspin/receiver_test.go | 595 ++++ .../pkg/sendspin/role_controller.go | 78 + .../pkg/sendspin/role_controller_test.go | 104 + .../sendspin-go/pkg/sendspin/role_metadata.go | 150 + .../pkg/sendspin/role_metadata_test.go | 416 +++ .../sendspin-go/pkg/sendspin/role_player.go | 161 + .../pkg/sendspin/role_player_test.go | 70 + .../sendspin-go/pkg/sendspin/scheduler.go | 229 ++ .../pkg/sendspin/scheduler_test.go | 70 + .../sendspin-go/pkg/sendspin/server.go | 647 ++++ .../sendspin-go/pkg/sendspin/server_client.go | 240 ++ .../pkg/sendspin/server_client_test.go | 227 ++ .../pkg/sendspin/server_dispatch.go | 131 + .../sendspin-go/pkg/sendspin/server_stream.go | 260 ++ .../sendspin-go/pkg/sendspin/server_test.go | 985 ++++++ .../sendspin-go/pkg/sendspin/source.go | 102 + third_party/sendspin-go/pkg/sync/clock.go | 132 + .../sendspin-go/pkg/sync/clock_test.go | 198 ++ third_party/sendspin-go/pkg/sync/doc.go | 11 + .../sendspin-go/pkg/sync/timefilter.go | 239 ++ .../sendspin-go/pkg/sync/timefilter_test.go | 321 ++ .../sendspin-go/scripts/quickstart-pi.sh | 298 ++ 181 files changed, 47406 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .gitignore create mode 100644 Dockerfile.cross create mode 100644 Makefile create mode 100644 README.md create mode 100644 README.md.1 create mode 100644 cmd/client/main.go create mode 100644 cmd/live-server/main.go create mode 100644 cmd/server/main.go create mode 100644 dist/systemd/sendspin-client.service create mode 100644 dist/systemd/sendspin-server.service create mode 100644 esphome/speaker.yaml create mode 100644 file.wav create mode 100644 go.mod create mode 100644 go.sum create mode 100644 home-assistant-voice.yaml create mode 100644 internal/live/errors.go create mode 100644 internal/live/http.go create mode 100644 internal/live/manager.go create mode 100644 internal/live/mdns.go create mode 100644 internal/live/mqtt.go create mode 100644 internal/live/preset.go create mode 100644 internal/live/roster.go create mode 100644 internal/live/source.go create mode 100644 internal/live/web.go create mode 100644 internal/live/web/index.html create mode 100644 internal/pipewire/nodes.go create mode 100644 internal/pipewire/output.go create mode 100644 internal/pipewire/source.go create mode 100644 live-server-spec.md create mode 100644 out.raw create mode 100644 sendspin-spec.md create mode 100644 third_party/sendspin-go/.github/workflows/ci.yml create mode 100644 third_party/sendspin-go/.github/workflows/conformance.yml create mode 100644 third_party/sendspin-go/.github/workflows/release.yml create mode 100644 third_party/sendspin-go/.gitignore create mode 100644 third_party/sendspin-go/.golangci.yml create mode 100644 third_party/sendspin-go/.pre-commit-config.yaml create mode 100644 third_party/sendspin-go/CHANGELOG.md create mode 100644 third_party/sendspin-go/CLAUDE.md create mode 100644 third_party/sendspin-go/LICENSE create mode 100644 third_party/sendspin-go/Makefile create mode 100644 third_party/sendspin-go/README.md create mode 100644 third_party/sendspin-go/cmd/sendspin-server/main.go create mode 100644 third_party/sendspin-go/docs/2026-04-12-layered-architecture-design.md create mode 100644 third_party/sendspin-go/docs/CLOCK_SYNC_ANALYSIS.md create mode 100644 third_party/sendspin-go/docs/CODE_REVIEW_FIXES.md create mode 100644 third_party/sendspin-go/docs/FORMAT_NEGOTIATION_FIX.md create mode 100644 third_party/sendspin-go/docs/HIRES_TEST_RESULTS.md create mode 100644 third_party/sendspin-go/docs/MA_HIRES_ISSUE.md create mode 100644 third_party/sendspin-go/docs/NATIVE_RATE_FIX.md create mode 100644 third_party/sendspin-go/docs/PHASE1_IMPLEMENTATION.md create mode 100644 third_party/sendspin-go/docs/hires-audio-verification.md create mode 100644 third_party/sendspin-go/docs/plans/2025-10-23-resonate-player-design.md create mode 100644 third_party/sendspin-go/docs/plans/2025-10-23-resonate-player-implementation.md create mode 100644 third_party/sendspin-go/docs/plans/2025-10-25-library-refactor-design.md create mode 100644 third_party/sendspin-go/docs/plans/2025-10-25-library-refactor-plan.md create mode 100644 third_party/sendspin-go/docs/plans/phase1-hires-fixes.md create mode 100644 third_party/sendspin-go/docs/superpowers/plans/2026-04-12-layered-architecture.md create mode 100644 third_party/sendspin-go/docs/superpowers/plans/2026-04-14-drop-oto-backend.md create mode 100644 third_party/sendspin-go/docs/superpowers/plans/2026-04-14-rip-legacy-cli-stack.md create mode 100644 third_party/sendspin-go/docs/superpowers/plans/2026-04-14-server-initiated-client-discovery.md create mode 100644 third_party/sendspin-go/docs/superpowers/plans/2026-04-20-server-config-yaml.md create mode 100644 third_party/sendspin-go/docs/superpowers/plans/2026-05-01-quickstart-pi.md create mode 100644 third_party/sendspin-go/docs/superpowers/specs/2026-04-20-server-config-yaml-design.md create mode 100644 third_party/sendspin-go/docs/superpowers/specs/2026-05-01-quickstart-pi-design.md create mode 100644 third_party/sendspin-go/examples/README.md create mode 100644 third_party/sendspin-go/examples/basic-player/README.md create mode 100644 third_party/sendspin-go/examples/basic-player/main.go create mode 100644 third_party/sendspin-go/examples/basic-server/README.md create mode 100644 third_party/sendspin-go/examples/basic-server/main.go create mode 100644 third_party/sendspin-go/examples/custom-source/README.md create mode 100644 third_party/sendspin-go/examples/custom-source/main.go create mode 100644 third_party/sendspin-go/go.mod create mode 100644 third_party/sendspin-go/go.sum create mode 100644 third_party/sendspin-go/install-deps.sh create mode 100644 third_party/sendspin-go/internal/discovery/mdns.go create mode 100644 third_party/sendspin-go/internal/discovery/mdns_test.go create mode 100644 third_party/sendspin-go/internal/discovery/txt.go create mode 100644 third_party/sendspin-go/internal/discovery/txt_test.go create mode 100644 third_party/sendspin-go/internal/server/audio_engine.go create mode 100644 third_party/sendspin-go/internal/server/audio_engine_test.go create mode 100644 third_party/sendspin-go/internal/server/audio_source.go create mode 100644 third_party/sendspin-go/internal/server/flac_encoder.go create mode 100644 third_party/sendspin-go/internal/server/flac_encoder_test.go create mode 100644 third_party/sendspin-go/internal/server/opus_encoder.go create mode 100644 third_party/sendspin-go/internal/server/opus_encoder_test.go create mode 100644 third_party/sendspin-go/internal/server/resampler.go create mode 100644 third_party/sendspin-go/internal/server/resampler_test.go create mode 100644 third_party/sendspin-go/internal/server/server.go create mode 100644 third_party/sendspin-go/internal/server/test_tone_source.go create mode 100644 third_party/sendspin-go/internal/server/tui.go create mode 100644 third_party/sendspin-go/internal/server/tui_update.go create mode 100644 third_party/sendspin-go/internal/ui/device_picker.go create mode 100644 third_party/sendspin-go/internal/ui/device_picker_test.go create mode 100644 third_party/sendspin-go/internal/ui/hotkey.go create mode 100644 third_party/sendspin-go/internal/ui/hotkey_test.go create mode 100644 third_party/sendspin-go/internal/ui/model.go create mode 100644 third_party/sendspin-go/internal/ui/model_test.go create mode 100644 third_party/sendspin-go/internal/ui/tui.go create mode 100644 third_party/sendspin-go/internal/version/version.go create mode 100644 third_party/sendspin-go/internal/version/version_test.go create mode 100644 third_party/sendspin-go/main.go create mode 100644 third_party/sendspin-go/pkg/audio/decode/decoder.go create mode 100644 third_party/sendspin-go/pkg/audio/decode/doc.go create mode 100644 third_party/sendspin-go/pkg/audio/decode/flac.go create mode 100644 third_party/sendspin-go/pkg/audio/decode/flac_integration_test.go create mode 100644 third_party/sendspin-go/pkg/audio/decode/flac_test.go create mode 100644 third_party/sendspin-go/pkg/audio/decode/opus.go create mode 100644 third_party/sendspin-go/pkg/audio/decode/opus_test.go create mode 100644 third_party/sendspin-go/pkg/audio/decode/pcm.go create mode 100644 third_party/sendspin-go/pkg/audio/decode/pcm_test.go create mode 100644 third_party/sendspin-go/pkg/audio/doc.go create mode 100644 third_party/sendspin-go/pkg/audio/encode/doc.go create mode 100644 third_party/sendspin-go/pkg/audio/encode/encoder.go create mode 100644 third_party/sendspin-go/pkg/audio/encode/opus.go create mode 100644 third_party/sendspin-go/pkg/audio/encode/opus_test.go create mode 100644 third_party/sendspin-go/pkg/audio/encode/pcm.go create mode 100644 third_party/sendspin-go/pkg/audio/encode/pcm_test.go create mode 100644 third_party/sendspin-go/pkg/audio/output/doc.go create mode 100644 third_party/sendspin-go/pkg/audio/output/malgo.go create mode 100644 third_party/sendspin-go/pkg/audio/output/malgo_test.go create mode 100644 third_party/sendspin-go/pkg/audio/output/output.go create mode 100644 third_party/sendspin-go/pkg/audio/output/output_test.go create mode 100644 third_party/sendspin-go/pkg/audio/output/query.go create mode 100644 third_party/sendspin-go/pkg/audio/output/query_test.go create mode 100644 third_party/sendspin-go/pkg/audio/output/volume.go create mode 100644 third_party/sendspin-go/pkg/audio/output/volume_test.go create mode 100644 third_party/sendspin-go/pkg/audio/resample.go create mode 100644 third_party/sendspin-go/pkg/audio/types.go create mode 100644 third_party/sendspin-go/pkg/audio/types_test.go create mode 100644 third_party/sendspin-go/pkg/discovery/doc.go create mode 100644 third_party/sendspin-go/pkg/discovery/mdns.go create mode 100644 third_party/sendspin-go/pkg/discovery/mdns_test.go create mode 100644 third_party/sendspin-go/pkg/protocol/client.go create mode 100644 third_party/sendspin-go/pkg/protocol/client_test.go create mode 100644 third_party/sendspin-go/pkg/protocol/doc.go create mode 100644 third_party/sendspin-go/pkg/protocol/messages.go create mode 100644 third_party/sendspin-go/pkg/protocol/messages_test.go create mode 100644 third_party/sendspin-go/pkg/protocol/server_conn.go create mode 100644 third_party/sendspin-go/pkg/protocol/server_conn_test.go create mode 100644 third_party/sendspin-go/pkg/sendspin/buffer_tracker.go create mode 100644 third_party/sendspin-go/pkg/sendspin/buffer_tracker_test.go create mode 100644 third_party/sendspin-go/pkg/sendspin/client_dialer.go create mode 100644 third_party/sendspin-go/pkg/sendspin/client_dialer_test.go create mode 100644 third_party/sendspin-go/pkg/sendspin/client_discovery_integration_test.go create mode 100644 third_party/sendspin-go/pkg/sendspin/client_id.go create mode 100644 third_party/sendspin-go/pkg/sendspin/client_id_test.go create mode 100644 third_party/sendspin-go/pkg/sendspin/config.go create mode 100644 third_party/sendspin-go/pkg/sendspin/config_server_test.go create mode 100644 third_party/sendspin-go/pkg/sendspin/config_test.go create mode 100644 third_party/sendspin-go/pkg/sendspin/doc.go create mode 100644 third_party/sendspin-go/pkg/sendspin/group.go create mode 100644 third_party/sendspin-go/pkg/sendspin/group_role.go create mode 100644 third_party/sendspin-go/pkg/sendspin/group_role_test.go create mode 100644 third_party/sendspin-go/pkg/sendspin/group_test.go create mode 100644 third_party/sendspin-go/pkg/sendspin/player.go create mode 100644 third_party/sendspin-go/pkg/sendspin/player_test.go create mode 100644 third_party/sendspin-go/pkg/sendspin/receiver.go create mode 100644 third_party/sendspin-go/pkg/sendspin/receiver_test.go create mode 100644 third_party/sendspin-go/pkg/sendspin/role_controller.go create mode 100644 third_party/sendspin-go/pkg/sendspin/role_controller_test.go create mode 100644 third_party/sendspin-go/pkg/sendspin/role_metadata.go create mode 100644 third_party/sendspin-go/pkg/sendspin/role_metadata_test.go create mode 100644 third_party/sendspin-go/pkg/sendspin/role_player.go create mode 100644 third_party/sendspin-go/pkg/sendspin/role_player_test.go create mode 100644 third_party/sendspin-go/pkg/sendspin/scheduler.go create mode 100644 third_party/sendspin-go/pkg/sendspin/scheduler_test.go create mode 100644 third_party/sendspin-go/pkg/sendspin/server.go create mode 100644 third_party/sendspin-go/pkg/sendspin/server_client.go create mode 100644 third_party/sendspin-go/pkg/sendspin/server_client_test.go create mode 100644 third_party/sendspin-go/pkg/sendspin/server_dispatch.go create mode 100644 third_party/sendspin-go/pkg/sendspin/server_stream.go create mode 100644 third_party/sendspin-go/pkg/sendspin/server_test.go create mode 100644 third_party/sendspin-go/pkg/sendspin/source.go create mode 100644 third_party/sendspin-go/pkg/sync/clock.go create mode 100644 third_party/sendspin-go/pkg/sync/clock_test.go create mode 100644 third_party/sendspin-go/pkg/sync/doc.go create mode 100644 third_party/sendspin-go/pkg/sync/timefilter.go create mode 100644 third_party/sendspin-go/pkg/sync/timefilter_test.go create mode 100644 third_party/sendspin-go/scripts/quickstart-pi.sh diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..36ac5cc --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,53 @@ +{ + "permissions": { + "allow": [ + "WebFetch(domain:raw.githubusercontent.com)", + "Bash(curl -s \"https://api.github.com/repos/Sendspin/sendspin-go/git/trees/main?recursive=1\")", + "Bash(curl -s https://raw.githubusercontent.com/Sendspin/sendspin-go/refs/heads/main/pkg/sendspin/player.go)", + "Bash(curl -s https://raw.githubusercontent.com/Sendspin/sendspin-go/refs/heads/main/pkg/sendspin/server.go)", + "Bash(curl -s https://raw.githubusercontent.com/Sendspin/sendspin-go/refs/heads/main/pkg/audio/output/output.go)", + "Bash(curl -s https://raw.githubusercontent.com/Sendspin/sendspin-go/refs/heads/main/pkg/sendspin/source.go)", + "Bash(curl -s https://raw.githubusercontent.com/Sendspin/sendspin-go/refs/heads/main/go.mod)", + "Bash(go version *)", + "Bash(go get *)", + "Bash(GOFLAGS=\"\" go get github.com/Sendspin/sendspin-go@main)", + "Bash(go env *)", + "Bash(GONOSUMCHECK=* GONOSUMDB=* GOFLAGS='' go get github.com/Sendspin/sendspin-go@v0.0.0-20250503162011-af11b2c17c62)", + "Bash(GOPROXY=direct go list -m -versions github.com/Sendspin/sendspin-go)", + "Bash(GOPROXY=direct go list -m -json github.com/Sendspin/sendspin-go@latest)", + "Bash(GOPROXY=direct go get github.com/Sendspin/sendspin-go@v1.7.0)", + "Bash(GOPROXY=direct go mod tidy)", + "Bash(go build *)", + "Bash(sudo pacman -S --noconfirm opus opusfile)", + "Bash(./client --help)", + "Bash(./server --help)", + "Bash(go run *)", + "Bash(make -C /home/ficik/Workspaces/Personal/rpi-sendspin)", + "Bash(pacman -Q)", + "Bash(docker build *)", + "Bash(docker run *)", + "Bash(grep -n 'ResolveClientID\\\\|PersistFn' /home/ficik/go/pkg/mod/github.com/\\\\!sendspin/sendspin-go@v1.7.0/pkg/sendspin/*.go)", + "Read(//home/ficik/go/pkg/mod/github.com/!sendspin/sendspin-go@v1.7.0/pkg/sendspin/**)", + "WebFetch(domain:github.com)", + "WebFetch(domain:api.github.com)", + "Bash(find ~ -path \"*sendspin-go*\" -name \"*.go\" 2>/dev/null | head -30; echo \"---\"; cat /home/ficik/Workspaces/Personal/rpi-sendspin/go.mod)", + "Read(//home/ficik/**)", + "Bash(go list *)", + "Bash(cp -r \"/home/ficik/go/pkg/mod/github.com/!sendspin/sendspin-go@v1.7.0\" /home/ficik/Workspaces/Personal/rpi-sendspin/third_party/sendspin-go)", + "Bash(chmod -R u+w /home/ficik/Workspaces/Personal/rpi-sendspin/third_party/sendspin-go)", + "Bash(go vet *)", + "Bash(go test *)", + "Bash(make clean *)", + "Bash(make build *)", + "Bash(make live-server *)", + "Bash(./bin/sendspin-live-server --help)", + "Bash(cat)", + "Bash(rm /tmp/volume_test.go)", + "Bash(mv /tmp/volume_test.go /tmp/voltest.go)", + "Bash(rm /tmp/voltest.go)" + ], + "additionalDirectories": [ + "/tmp" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..24e091b --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +bin/* +/deploy.sh \ No newline at end of file diff --git a/Dockerfile.cross b/Dockerfile.cross new file mode 100644 index 0000000..251e000 --- /dev/null +++ b/Dockerfile.cross @@ -0,0 +1,63 @@ +FROM golang:1.24-bookworm + +# Enable ARM multiarch and install cross-compilers + ARM versions of CGo deps +RUN dpkg --add-architecture armhf \ + && dpkg --add-architecture arm64 \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + crossbuild-essential-armhf \ + crossbuild-essential-arm64 \ + pkg-config \ + libasound2-dev:armhf \ + libopus-dev:armhf \ + libopusfile-dev:armhf \ + libogg-dev:armhf \ + libasound2-dev:arm64 \ + libopus-dev:arm64 \ + libopusfile-dev:arm64 \ + libogg-dev:arm64 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN mkdir -p /out + +# armhf (RPi 2/3/4 32-bit, RPi Zero 2 W 32-bit) +RUN CGO_ENABLED=1 \ + CC=arm-linux-gnueabihf-gcc \ + PKG_CONFIG_LIBDIR=/usr/lib/arm-linux-gnueabihf/pkgconfig \ + GOOS=linux GOARCH=arm GOARM=7 \ + go build -buildvcs=false -o /out/sendspin-client-armhf ./cmd/client/ \ + && CGO_ENABLED=1 \ + CC=arm-linux-gnueabihf-gcc \ + PKG_CONFIG_LIBDIR=/usr/lib/arm-linux-gnueabihf/pkgconfig \ + GOOS=linux GOARCH=arm GOARM=7 \ + go build -buildvcs=false -o /out/sendspin-server-armhf ./cmd/server/ \ + && CGO_ENABLED=1 \ + CC=arm-linux-gnueabihf-gcc \ + PKG_CONFIG_LIBDIR=/usr/lib/arm-linux-gnueabihf/pkgconfig \ + GOOS=linux GOARCH=arm GOARM=7 \ + go build -buildvcs=false -o /out/sendspin-live-server-armhf ./cmd/live-server/ + +# arm64 (RPi 3/4/5 64-bit) +RUN CGO_ENABLED=1 \ + CC=aarch64-linux-gnu-gcc \ + PKG_CONFIG_LIBDIR=/usr/lib/aarch64-linux-gnu/pkgconfig \ + GOOS=linux GOARCH=arm64 \ + go build -buildvcs=false -o /out/sendspin-client-arm64 ./cmd/client/ \ + && CGO_ENABLED=1 \ + CC=aarch64-linux-gnu-gcc \ + PKG_CONFIG_LIBDIR=/usr/lib/aarch64-linux-gnu/pkgconfig \ + GOOS=linux GOARCH=arm64 \ + go build -buildvcs=false -o /out/sendspin-server-arm64 ./cmd/server/ \ + && CGO_ENABLED=1 \ + CC=aarch64-linux-gnu-gcc \ + PKG_CONFIG_LIBDIR=/usr/lib/aarch64-linux-gnu/pkgconfig \ + GOOS=linux GOARCH=arm64 \ + go build -buildvcs=false -o /out/sendspin-live-server-arm64 ./cmd/live-server/ + +CMD ["sh", "-c", "cp /out/* /binaries/"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..171ad02 --- /dev/null +++ b/Makefile @@ -0,0 +1,35 @@ +BINDIR := bin +CLIENT := $(BINDIR)/sendspin-client +SERVER := $(BINDIR)/sendspin-server +LIVE_SERVER := $(BINDIR)/sendspin-live-server +CROSS_IMAGE := rpi-sendspin-cross + +.PHONY: all build client server live-server rpi test vet clean + +all: build + +build: client server live-server + +client: + go build -o $(CLIENT) ./cmd/client/ + +server: + go build -o $(SERVER) ./cmd/server/ + +live-server: + go build -o $(LIVE_SERVER) ./cmd/live-server/ + +test: + go test ./... + +vet: + go vet ./... + +# Cross-compile for RPi via Docker (armhf + arm64) +rpi: + docker build --network=host -f Dockerfile.cross -t $(CROSS_IMAGE) . + mkdir -p $(BINDIR) + docker run --rm --network=host -v $(CURDIR)/$(BINDIR):/binaries $(CROSS_IMAGE) + +clean: + rm -rf $(BINDIR) diff --git a/README.md b/README.md new file mode 100644 index 0000000..0a4f24f --- /dev/null +++ b/README.md @@ -0,0 +1,114 @@ +# rpi-sendspin + +A [sendspin](https://github.com/Sendspin/sendspin-go) client and server with native PipeWire node selection. +Audio is routed through `pw-cat`, so any PipeWire source or sink can be targeted by name — no ALSA/PulseAudio compatibility layer needed. + +## Requirements + +- Go 1.22+ +- PipeWire with `pw-cat` and `pw-dump` in `$PATH` +- `libopus` and `libopusfile` (for the sendspin dependency) + +**Build host** (dev packages, only needed to compile): +```bash +# Arch +sudo pacman -S pipewire opus opusfile + +# Debian/Ubuntu +sudo apt install libopus-dev libopusfile-dev libasound2-dev +``` + +**Raspberry Pi** (runtime only, no dev packages needed): +```bash +sudo apt install libopus0 libopusfile0 libasound2 pipewire +``` + +## Build + +```bash +make # builds bin/sendspin-client and bin/sendspin-server (native) +make rpi # cross-compiles for RPi via Docker → bin/*-armhf and bin/*-arm64 +make clean +``` + +`make rpi` requires Docker. It builds a Debian image with ARM cross-compilers and the ARM versions of libopus/libasound, then copies the four binaries into `bin/`. No toolchain installation needed on the host. + +## Usage + +### Server + +Captures audio from a PipeWire source node and streams it to connected clients. + +```bash +# List available capture sources +./bin/sendspin-server --list-sources + +# Stream from a specific source +./bin/sendspin-server --source alsa_input.usb-JBL_Quantum810-00.pro-input-0 + +# Options +./bin/sendspin-server --help + -source string PipeWire source node name + -port int listen port (default 8927) + -name string server name (default "Sendspin Server") + -rate int capture sample rate (default 48000) + -channels int capture channels (default 2) +``` + +### Client + +Connects to a sendspin server and plays audio on a PipeWire sink. + +```bash +# List available playback sinks +./bin/sendspin-client --list-devices + +# Play on a specific sink (omit --device to use the system default) +./bin/sendspin-client --server rpi.local:8927 --device alsa_output.usb-JBL_Quantum810-00.pro-output-0 + +# Options +./bin/sendspin-client --help + -server string server address (default "localhost:8927") + -device string PipeWire sink node name (default: system default) + -name string player name (default: hostname) + -volume int initial volume 0-100 (default 80) +``` + +## Running as a systemd user service + +Example unit files are in [dist/systemd/](dist/systemd/). Both run as a **user** service so they have access to the PipeWire session without any special privileges. + +```bash +# 1. Copy the binary +install -Dm755 bin/sendspin-client-arm64 ~/.local/bin/sendspin-client + +# 2. Edit the unit file — set --server, --device, --name to your values +cp dist/systemd/sendspin-client.service ~/.config/systemd/user/ + +# 3. Enable and start +systemctl --user daemon-reload +systemctl --user enable --now sendspin-client + +# Logs +journalctl --user -u sendspin-client -f +``` + +The unit files use `%H` (hostname) for `--name` and `%h` (home dir) for the binary path. Replace the placeholder `--device` / `--source` values — use `--list-devices` / `--list-sources` to find the right node name first. + +## Architecture + +``` +PipeWire source node + │ + pw-cat --capture + │ int32 PCM + sendspin server ──── network ──── sendspin client + │ int32 PCM + pw-cat --playback + │ + PipeWire sink node +``` + +`internal/pipewire/source.go` wraps `pw-cat --capture` as an `AudioSource`. +`internal/pipewire/output.go` wraps `pw-cat --playback` as an `Output`. +`internal/pipewire/nodes.go` parses `pw-dump` JSON to enumerate nodes. diff --git a/README.md.1 b/README.md.1 new file mode 100644 index 0000000..39e8870 --- /dev/null +++ b/README.md.1 @@ -0,0 +1,752 @@ +# The Sendspin Protocol + +_This is raw, unfiltered and experimental._ + +Sendspin is a multi-room music experience protocol. The goal of the protocol is to orchestrate all devices that make up the music listening experience. This includes outputting audio on multiple speakers simultaneously, screens and lights visualizing the audio or album art, and wall tablets providing media controls. + +## Definitions + +- **Sendspin Server** - orchestrates all devices, generates audio streams, manages players and clients, provides metadata +- **Sendspin Client** - a client that can play audio, visualize audio, display metadata, display colors, or provide music controls. Has different possible roles (player, metadata, controller, artwork, visualizer, color). Every client has a unique identifier + - **Player** - receives audio and plays it in sync. Has its own volume and mute state and preferred format settings + - **Controller** - controls the Sendspin group this client is part of + - **Metadata** - displays text metadata (title, artist, album, etc.) + - **Artwork** - displays artwork images. Has preferred format for images + - **Visualizer** - visualizes music. Has preferred format for audio features + - **Color** - receives colors derived from the current audio +- **Sendspin Group** - a group of clients. Each client belongs to exactly one group, and every group has at least one client. Every group has a unique identifier. Each group has the following states: list of member clients, volume, mute, and playback state +- **Sendspin Stream** - client-specific details on how the server is formatting and sending binary data. Each role's stream is managed separately. Each client receives its own independently encoded stream based on its capabilities and preferences. For players, the server sends audio chunks as far ahead as the client's buffer capacity allows. For artwork clients, the server sends album artwork and other visual images through the stream + +## Role Versioning + +Roles define what capabilities and responsibilities a client has. All roles use explicit versioning with the `@` character: `@` (e.g., `player@v1`, `controller@v1`). + +This specification defines the following roles: [`player`](#player-messages), [`controller`](#controller-messages), [`metadata`](#metadata-messages), [`artwork`](#artwork-messages), [`visualizer`](#visualizer-messages), [`color`](#color-messages). All servers must implement all versions of these roles described in this specification. + +All role names and versions not starting with `_` are reserved for future revisions of this specification. + +### Priority and Activation + +Clients list roles in `supported_roles` in priority order (most preferred first). If a client supports multiple versions of a role, all should be listed: `["player@v2", "player@v1"]`. + +The server activates one version per role family (e.g., one `player@vN`, one `controller@vN`)—the first match it implements from the client's list. The server reports activated roles in `active_roles`. + +Message object keys (e.g., `player?`, `controller?`) use unversioned role names. The server determines the appropriate version from the client's `active_roles`. + +### Detecting Outdated Servers + +Servers should track when clients request roles or role versions they don't implement (excluding those starting with `_`). This indicates the client supports a newer version of the specification and the server needs to be updated. + +### Application-Specific Roles + +Custom roles outside the specification start with `_` (e.g., `_myapp_controller`, `_custom_display`). Application-specific roles can also be versioned: `_myapp_visualizer@v2`. + +## Establishing a Connection + +Sendspin has two standard ways to establish connections: Server and Client initiated. Server Initiated connections are recommended as they provide standardized multi-server behavior, but require mDNS which may not be available in all environments. + +Sendspin Servers must support both methods described below. + +### Server Initiated Connections + +Clients announce their presence via mDNS using: +- Service type: `_sendspin._tcp.local.` +- Port: The port the Sendspin client is listening on (recommended: `8928`) +- TXT record: `path` key specifying the WebSocket endpoint (recommended: `/sendspin`) +- TXT record: `name` key specifying the friendly name of the player (optional) + +The server discovers available clients through mDNS and connects to each client via WebSocket using the advertised address and path. + +**Note:** Do not manually connect to servers if you are advertising `_sendspin._tcp`. + +#### Multiple Servers + +In environments with multiple Sendspin servers, servers may need to reconnect to clients when starting playback to reclaim them. The [`server/hello`](#server--client-serverhello) message includes a `connection_reason` field indicating whether the server is connecting for general availability (`'discovery'`) or for active/upcoming playback (`'playback'`). + +Clients can only be connected to one server at a time. Clients must persistently store the `server_id` of the server that most recently had `playback_state: 'playing'` (the "last played server"). + +When a second server connects, clients must: + +1. **Accept incoming connections**: Complete the handshake (send [`client/hello`](#client--server-clienthello), receive [`server/hello`](#server--client-serverhello)) with the new server before making any decisions. + +2. **Decide which server to keep**: + - If the new server's `connection_reason` is `'playback'` → switch to new server + - If the new server's `connection_reason` is `'discovery'` and the existing server connected with `'playback'` → keep existing server + - If both servers have `connection_reason: 'discovery'`: + - Prefer the server matching the stored last played server + - If neither matches (or no history), keep the existing server + +3. **Disconnect**: Send [`client/goodbye`](#client--server-clientgoodbye) with reason `'another_server'` to the server being disconnected, then close the connection. + +### Client Initiated Connections + +If clients prefer to initiate the connection instead of waiting for the server to connect, the server must be discoverable via mDNS using: +- Service type: `_sendspin-server._tcp.local.` +- Port: The port the Sendspin server is listening on (recommended: `8927`) +- TXT record: `path` key specifying the WebSocket endpoint (recommended: `/sendspin`) +- TXT record: `name` key specifying the friendly name of the server (optional) + +Clients discover the server through mDNS and initiate a WebSocket connection using the advertised address and path. + +**Note:** Do not advertise `_sendspin._tcp` if the client plans to initiate the connection. + +#### Multiple Servers + +Unlike server-initiated connections, servers cannot reclaim clients by reconnecting. How clients handle multiple discovered servers, server selection, and switching is implementation-defined. + +**Note:** After this point, Sendspin works independently of how the connection was established. The Sendspin client is always the consumer of data like audio or metadata, regardless of who initiated the connection. + +While custom connection methods are possible for specialized use cases (like remotely accessible web-browsers, mobile apps), most clients should use one of the two standardized methods above if possible. + +## Communication + +Once the connection is established, Client and Server are going to talk. + +The first message must always be a `client/hello` message from the client to the server. +Once the server receives this message, it responds with a `server/hello` message. Before this handshake is complete, no other messages should be sent. + +WebSocket text messages are used to send JSON payloads. + +**Note:** In field definitions, `?` indicates an optional field (e.g., `field?`: type means the field may be omitted). + +All messages have a `type` field identifying the message and a `payload` object containing message-specific data. The payload structure varies by message type and is detailed in each message section below. + +Message format example: + +```json +{ + "type": "stream/start", + "payload": { + "player": { + "codec": "opus", + "sample_rate": 48000, + "channels": 2, + "bit_depth": 16 + }, + "artwork": { + "channels": [ + { + "source": "album", + "format": "jpeg", + "width": 800, + "height": 800 + } + ] + } + } +} +``` + +WebSocket binary messages are used to send audio chunks, media art, and visualization data. The first byte is a uint8 representing the message type. + +### Binary Message ID Structure + +Binary message IDs typically use **bits 7-2** for role type and **bits 1-0** for message slot, allocating 4 IDs per role. Roles with expanded allocations use **bits 2-0** for message slot (8 IDs). + +**Role assignments:** +- `000000xx` (0-3): Reserved for future use +- `000001xx` (4-7): Player role +- `000010xx` (8-11): Artwork role +- `000011xx` (12-15): Reserved for a future role +- `00010xxx` (16-23): Visualizer role +- Roles 6-47 (IDs 24-191): Reserved for future roles +- Roles 48-63 (IDs 192-255): Available for use by [application-specific roles](#application-specific-roles) + +**Message slots:** +- Slot 0: `xxxxxx00` +- Slot 1: `xxxxxx01` +- Slot 2: `xxxxxx10` +- Slot 3: `xxxxxx11` + +Roles with expanded allocations have slots 0-7. + +**Note:** Role versions share the same binary message IDs (e.g., `player@v1` and `player@v2` both use IDs 4-7). + +## Clock Synchronization + +Clients continuously send `client/time` messages to maintain an accurate offset from the server's clock. The frequency of these messages is determined by the client based on network conditions and clock stability. + +Binary audio messages contain timestamps in the server's time domain indicating when the audio should be played. Clients must use the [time-filter](https://github.com/Sendspin-Protocol/time-filter) algorithm to translate server timestamps to their local clock for synchronized playback. The time filter is a two-dimensional Kalman filter that tracks both clock offset and drift. See the [time-filter](https://github.com/Sendspin-Protocol/time-filter) repository for a C++ reference implementation and [aiosendspin](https://github.com/Sendspin-Protocol/aiosendspin/blob/main/aiosendspin/client/time_sync.py) for a Python implementation. + +Each [`server/time`](#server--client-servertime) response provides the four timestamps needed by the filter: the client's transmitted timestamp, the server's received timestamp, the server's transmitted timestamp, and the client's receive time (captured locally when the response arrives). Clients feed these into the time filter via its `update` method and use its `compute_client_time` method to convert server timestamps to local clock values for playback scheduling. + +## Playback Synchronization + +- Each client is responsible for maintaining synchronization with the server's timestamps +- Clients maintain accurate sync by adding or removing samples using interpolation to compensate for clock drift +- When a client cannot maintain sync (e.g., buffer underrun), it should send `state: 'error'` via [`client/state`](#client--server-clientstate), mute its audio output, and continue buffering until it can resume synchronized playback, at which point it should send `state: 'synchronized'` +- The server is unaware of individual client synchronization accuracy - it simply broadcasts timestamped audio +- The server sends audio to late-joining clients with future timestamps only, allowing them to buffer and start playback in sync with existing clients +- Audio chunks may arrive with timestamps in the past due to network delays or buffering; clients should drop these late chunks to maintain sync +- Clients subtract their [`static_delay_ms`](#client--server-clientstate-player-object) from server timestamps before scheduling playback +- Servers factor in each client's `static_delay_ms` when calculating how far ahead to send audio, keeping effective buffer headroom constant + +```mermaid +sequenceDiagram + participant Client + participant Server + + Note over Client,Server: WebSocket connection established + + Note over Client,Server: Text messages = JSON payloads, Binary messages = Audio/Art/Visualization + + Client->>Server: client/hello (roles and capabilities) + Server->>Client: server/hello (server info, connection_reason) + + Client->>Server: client/state (state: synchronized) + alt Player role + Client->>Server: client/state (player: volume, muted) + end + + loop Continuous clock sync + Client->>Server: client/time (client clock) + Server->>Client: server/time (timing + offset info) + end + + alt Stream starts + Server->>Client: stream/start (codec, format details) + end + + Server->>Client: group/update (playback_state, group_id, group_name) + Server->>Client: server/state (metadata, controller, color) + + loop During playback + alt Player role + Server->>Client: binary Type 4 (audio chunks with timestamps) + end + alt Artwork role + Server->>Client: binary Types 8-11 (artwork channels 0-3) + end + alt Visualizer role + Server->>Client: binary Type 16 (visualization data) + end + end + + alt Player requests format change + Client->>Server: stream/request-format (codec, sample_rate, etc) + Server->>Client: stream/start (player: new format) + end + + alt Seek operation + Server->>Client: stream/clear (roles: [player, visualizer]) + end + + alt Controller role + Client->>Server: client/command (controller: play/pause/volume/switch/etc) + end + + alt State changes + Client->>Server: client/state (state and/or player changes) + end + + alt Server commands player + Server->>Client: server/command (player: volume, mute) + end + + Server->>Client: stream/end (ends all role streams) + + alt Graceful disconnect + Client->>Server: client/goodbye (reason) + Note over Client,Server: Server initiates disconnect + end +``` + +## Core messages +This section describes the fundamental messages that establish communication between clients and the server. These messages handle initial handshakes, ongoing clock synchronization, stream lifecycle management, and role-based state updates and commands. + +Every Sendspin client and server must implement all messages in this section regardless of their specific roles. Role-specific object details are documented in their respective role sections and need to be implemented only if the client supports that role. + +### Client → Server: `client/hello` + +First message sent by the client after establishing the WebSocket connection. Contains information about the client's capabilities and roles. +This message will be followed by a [`server/hello`](#server--client-serverhello) message from the server. + +Players that can output audio should have the role `player`. + +- `client_id`: string - uniquely identifies the client for groups and de-duplication. Should remain persistent across reconnections so servers can associate clients with previous sessions (e.g., remembering group membership, settings, playback queue) +- `name`: string - friendly name of the client +- `device_info?`: object - optional information about the device + - `product_name?`: string - device model/product name + - `manufacturer?`: string - device manufacturer name + - `software_version?`: string - software version of the client (not the Sendspin version) +- `version`: integer (must be `1`) - version of the core message format that the Sendspin client implements (independent of role versions) +- `supported_roles`: string[] - versioned roles supported by the client (e.g., `player@v1`, `controller@v1`). Defined versioned roles are: + - `player@v1` - outputs audio + - `controller@v1` - controls the current Sendspin group + - `metadata@v1` - displays text metadata describing the currently playing audio + - `artwork@v1` - displays artwork images + - `visualizer@v1` - visualizes audio + - `color@v1` - receives colors derived from the current audio +- `player@v1_support?`: object - only if `player@v1` is listed ([see player@v1 support object details](#client--server-clienthello-playerv1-support-object)) +- `artwork@v1_support?`: object - only if `artwork@v1` is listed ([see artwork@v1 support object details](#client--server-clienthello-artworkv1-support-object)) +- `visualizer@v1_support?`: object - only if `visualizer@v1` is listed ([see visualizer@v1 support object details](#client--server-clienthello-visualizerv1-support-object)) + +**Note:** Each role version may have its own support object (e.g., `player@v1_support`, `player@v2_support`). Application-specific roles or role versions follow the same pattern (e.g., `_myapp_display@v1_support`, `player@_experimental_support`). + +### Client → Server: `client/time` + +Sends current internal clock timestamp (in microseconds) to the server. +Once received, the server responds with a [`server/time`](#server--client-servertime) message containing timing information to establish clock offsets. + +- `client_transmitted`: integer - client's internal clock timestamp in microseconds + +### Server → Client: `server/hello` + +Response to the [`client/hello`](#client--server-clienthello) message with information about the server. + +Only after receiving this message should the client send any other messages (including [`client/time`](#client--server-clienttime) and the initial [`client/state`](#client--server-clientstate) message if the client has roles that require state updates). + +- `server_id`: string - identifier of the server +- `name`: string - friendly name of the server +- `version`: integer (must be `1`) - version of the core message format that the server implements (independent of role versions) +- `active_roles`: string[] - versioned roles that are active for this client (e.g., `player@v1`, `controller@v1`) +- `connection_reason`: 'discovery' | 'playback' - only used for [server-initiated connections](#multiple-servers) + - `discovery` - server is connecting for general availability (e.g., initial discovery, reconnection after connection loss) + - `playback` - server needs client for active or upcoming playback + +**Note:** Servers will always activate the client's [preferred](#priority-and-activation) version of each role. Checking `active_roles` is only necessary to detect outdated servers or confirm activation of [application-specific roles](#application-specific-roles). + +### Server → Client: `server/time` + +Response to the [`client/time`](#client--server-clienttime) message with timestamps to establish clock offsets. + +For synchronization, all timing is relative to the server's monotonic clock. These timestamps have microsecond precision and are not necessarily based on epoch time. + +- `client_transmitted`: integer - client's internal clock timestamp received in the `client/time` message +- `server_received`: integer - timestamp that the server received the `client/time` message in microseconds +- `server_transmitted`: integer - timestamp that the server transmitted this message in microseconds + +### Client → Server: `client/state` + +Client sends state updates to the server. Contains client-level state and role-specific state objects. + +Must be sent immediately after receiving [`server/hello`](#server--client-serverhello), and whenever any state changes thereafter. + +For the initial message, include all state fields. For subsequent updates, only include fields that have changed. The server will merge these updates into existing state. + +- `state`: 'synchronized' | 'error' | 'external_source' - operational state of the client + - `'synchronized'` - client is operational and synchronized with server timestamps + - `'error'` - client has a problem preventing normal operation (unable to keep up, clock sync issues, etc.) + - `'external_source'` - client is in use by an external system and is not currently participating in Sendspin playback with this server. See [External Source Handling](#external-source-handling) +- `player?`: object - only if client has `player` role ([see player state object details](#client--server-clientstate-player-object)) + +[Application-specific roles](#application-specific-roles) may also include objects in this message (keys starting with `_`). + +### External Source Handling + +When a client sets `state: 'external_source'`, it indicates the client's output is in use by an external system (e.g., a different audio source, HDMI input, or local media playback) and is not currently participating in Sendspin playback with this server. + +#### Server behavior when `state` changes to `'external_source'`: + +If the client is in a multi-client group: +1. Remember the client's current group as its "previous group" (see [switch command cycle](#switch-command-cycle)) +2. Move the client to a new solo group (stopped) + - Send [`group/update`](#server--client-groupupdate) with the new group information + - Send [`stream/end`](#server--client-streamend) for all active streams + +If the client is already in a solo group: +- Stop playback and send [`stream/end`](#server--client-streamend) for all active streams + +### Client → Server: `client/command` + +Client sends commands to the server. Contains command objects based on the client's supported roles. + +- `controller?`: object - only if client has `controller` role ([see controller command object details](#client--server-clientcommand-controller-object)) + +[Application-specific roles](#application-specific-roles) may also include objects in this message (keys starting with `_`). + +### Server → Client: `server/state` + +Server sends state updates to the client. Contains role-specific state objects. + +Only include fields that have changed. The client will merge these updates into existing state. Fields set to `null` should be cleared from the client's state. + +- `metadata?`: object - only sent to clients with `metadata` role ([see metadata state object details](#server--client-serverstate-metadata-object)) +- `controller?`: object - only sent to clients with `controller` role ([see controller state object details](#server--client-serverstate-controller-object)) +- `color?`: object - only sent to clients with `color` role ([see color state object details](#server--client-serverstate-color-object)) + +[Application-specific roles](#application-specific-roles) may also include objects in this message (keys starting with `_`). + +### Server → Client: `server/command` + +Server sends commands to the client. Contains role-specific command objects. + +- `player?`: object - only sent to clients with `player` role ([see player command object details](#server--client-servercommand-player-object)) + +[Application-specific roles](#application-specific-roles) may also include objects in this message (keys starting with `_`). + +### Server → Client: `stream/start` + +Starts a stream for one or more roles. If sent for a role that already has an active stream, updates the stream configuration without clearing buffers. + +- `player?`: object - only sent to clients with the `player` role ([see player object details](#server--client-streamstart-player-object)) +- `artwork?`: object - only sent to clients with the `artwork` role ([see artwork object details](#server--client-streamstart-artwork-object)) +- `visualizer?`: object - only sent to clients with the `visualizer` role ([see visualizer object details](#server--client-streamstart-visualizer-object)) + +[Application-specific roles](#application-specific-roles) may also include objects in this message (keys starting with `_`). + +### Server → Client: `stream/clear` + +Instructs clients to clear buffers without ending the stream. Used for seek operations. + +- `roles?`: string[] - which roles to clear: '[player](#server--client-streamclear-player)', '[visualizer](#server--client-streamclear-visualizer)', or both. If omitted, clears both roles + +[Application-specific roles](#application-specific-roles) may also be included in this array (names starting with `_`). + +### Client → Server: `stream/request-format` + +Request different stream format (upgrade or downgrade). Available for clients with the `player` or `artwork` role. + +- `player?`: object - only for clients with the `player` role ([see player object details](#client--server-streamrequest-format-player-object)) +- `artwork?`: object - only for clients with the `artwork` role ([see artwork object details](#client--server-streamrequest-format-artwork-object)) + +[Application-specific roles](#application-specific-roles) may also include objects in this message (keys starting with `_`). + +Response: [`stream/start`](#server--client-streamstart) for the requested role(s) with the new format. + +**Note:** Clients should use this message to adapt to changing network conditions, CPU constraints, or display requirements. The server maintains separate encoding for each client, allowing heterogeneous device capabilities within the same group. + +### Server → Client: `stream/end` + +Ends the stream for one or more roles. When received, clients should stop output and clear buffers for the specified roles. + +- `roles?`: string[] - roles to end streams for ('player', 'artwork', 'visualizer'). If omitted, ends all active streams + +[Application-specific roles](#application-specific-roles) may also be included in this array (names starting with `_`). + +### Server → Client: `group/update` + +State update of the group this client is part of. + +Contains delta updates with only the changed fields. The client should merge these updates into existing state. Fields set to `null` should be cleared from the client's state. + +- `playback_state?`: 'playing' | 'stopped' - playback state of the group +- `group_id?`: string - group identifier +- `group_name?`: string - friendly name of the group + +### Client → Server: `client/goodbye` + +Sent by the client before gracefully closing the connection. This allows the client to inform the server why it is disconnecting. + +Upon receiving this message, the server should initiate the disconnect. + +- `reason`: 'another_server' | 'shutdown' | 'restart' | 'user_request' + - `another_server` - client is switching to a different Sendspin server. Server should not auto-reconnect but should show the client as available for future playback + - `shutdown` - client is shutting down. Server should not auto-reconnect + - `restart` - client is restarting and will reconnect. Server should auto-reconnect + - `user_request` - user explicitly requested to disconnect from this server. Server should not auto-reconnect + +**Note:** Clients may close the connection without sending this message (e.g., crash, network loss), or immediately after sending `client/goodbye` without waiting for the server to disconnect. When a client disconnects without sending `client/goodbye`, servers should assume the disconnect reason is `restart` and attempt to auto-reconnect. + +## Player messages +This section describes messages specific to clients with the `player` role, which handle audio output and synchronized playback. Player clients receive timestamped audio data, manage their own volume and mute state, and can request different audio formats based on their capabilities and current conditions. + +**Note:** Volume values (0-100) represent perceived loudness, not linear amplitude (e.g., volume 50 should be perceived as half as loud as volume 100). Players must convert these values to appropriate amplitude for their audio hardware. + +### Client → Server: `client/hello` player@v1 support object + +The `player@v1_support` object in [`client/hello`](#client--server-clienthello) has this structure: + +- `player@v1_support`: object + - `supported_formats`: object[] - list of supported audio formats in priority order (first is preferred) + - `codec`: 'opus' | 'flac' | 'pcm' - codec identifier + - `channels`: integer - supported number of channels (e.g., 1 = mono, 2 = stereo) + - `sample_rate`: integer - sample rate in Hz (e.g., 44100) + - `bit_depth`: integer - bit depth for this format (e.g., 16, 24) + - `buffer_capacity`: integer - max size in bytes of compressed audio messages in the buffer that are yet to be played + - `supported_commands`: string[] - subset of: 'volume', 'mute' + +**Note:** Servers must support all audio codecs: 'opus', 'flac', and 'pcm'. + +**PCM Encoding Convention:** For the `pcm` codec, samples are encoded as little-endian signed integers (two's complement). 24-bit samples are packed as 3 bytes per sample. + +### Client → Server: `client/state` player object + +The `player` object in [`client/state`](#client--server-clientstate) has this structure: + +Informs the server of player-specific state changes. Only for clients with the `player` role. + +State updates must be sent whenever any state changes, including when the volume was changed through a `server/command` or via device controls. + +- `player`: object + - `volume?`: integer - range 0-100, must be included if 'volume' is in `supported_commands` from [`player@v1_support`](#client--server-clienthello-playerv1-support-object) + - `muted?`: boolean - mute state, must be included if 'mute' is in `supported_commands` from [`player@v1_support`](#client--server-clienthello-playerv1-support-object) + - `static_delay_ms`: integer - static delay in milliseconds (0-5000), always required for players + - `supported_commands?`: string[] - subset of: 'set_static_delay' + +**Static delay:** The default is 0, meaning audio exits the device's audio port at the timestamp. `static_delay_ms` compensates for additional delay beyond the port (external speakers, amplifiers). Negative values are not supported and should never be required for any compliant implementation. Clients must persist `static_delay_ms` locally across reboots and server reconnections. Clients may update `static_delay_ms` and `supported_commands` when audio output changes (e.g., external speaker connected), persisting separate delays per output. + +### Client → Server: `stream/request-format` player object + +The `player` object in [`stream/request-format`](#client--server-streamrequest-format) has this structure: + +- `player`: object + - `codec?`: 'opus' | 'flac' | 'pcm' - requested codec identifier + - `channels?`: integer - requested number of channels (e.g., 1 = mono, 2 = stereo) + - `sample_rate?`: integer - requested sample rate in Hz (e.g., 44100, 48000) + - `bit_depth?`: integer - requested bit depth (e.g., 16, 24) + +Response: [`stream/start`](#server--client-streamstart) with the new format. + +**Note:** Clients should use this message to adapt to changing network conditions or CPU constraints. The server maintains separate encoding for each client, allowing heterogeneous device capabilities within the same group. + +### Server → Client: `server/command` player object + +The `player` object in [`server/command`](#server--client-servercommand) has this structure: + +Request the player to perform an action, e.g., change volume or mute state. + +- `player`: object + - `command`: 'volume' | 'mute' | 'set_static_delay' - must be listed in `supported_commands` from [`player@v1_support`](#client--server-clienthello-playerv1-support-object) or from [`client/state`](#client--server-clientstate-player-object); unlisted commands are ignored by the client + - `volume?`: integer - volume range 0-100, only set if `command` is `volume` + - `mute?`: boolean - true to mute, false to unmute, only set if `command` is `mute` + - `static_delay_ms?`: integer - delay in milliseconds (0-5000), only set if `command` is `set_static_delay` + +### Server → Client: `stream/start` player object + +The `player` object in [`stream/start`](#server--client-streamstart) has this structure: + +- `player`: object + - `codec`: string - codec to be used + - `sample_rate`: integer - sample rate to be used + - `channels`: integer - channels to be used + - `bit_depth`: integer - bit depth to be used + - `codec_header?`: string - Base64 encoded codec header (if necessary; e.g., FLAC) + +### Server → Client: `stream/clear` player + +When [`stream/clear`](#server--client-streamclear) includes the player role, clients should clear all buffered audio chunks and continue with chunks received after this message. + +### Server → Client: Audio Chunks (Binary) + +Binary messages should be rejected if there is no active stream. + +- Byte 0: message type `4` (uint8) +- Bytes 1-8: timestamp (big-endian int64) - server clock time in microseconds when the first sample should be output +- Rest of bytes: encoded audio frame + +The timestamp indicates when the first audio sample in this chunk should be output. Clients must translate this server timestamp to their local clock using the offset computed from clock synchronization, subtracting their [`static_delay_ms`](#client--server-clientstate-player-object) from the timestamp. Clients should compensate for any known processing delays (e.g., DAC latency, audio buffer delays, amplifier delays) by accounting for these delays when submitting audio to the hardware. + +## Controller messages +This section describes messages specific to clients with the `controller` role, which enables the client to control the Sendspin group this client is part of, and switch between groups. + +Every client which lists the `controller` role in the `supported_roles` of the `client/hello` message needs to implement all messages in this section. + +### Client → Server: `client/command` controller object + +The `controller` object in [`client/command`](#client--server-clientcommand) has this structure: + +Control the group that's playing and switch groups. Only valid from clients with the `controller` role. + +- `controller`: object + - `command`: 'play' | 'pause' | 'stop' | 'next' | 'previous' | 'volume' | 'mute' | 'repeat_off' | 'repeat_one' | 'repeat_all' | 'shuffle' | 'unshuffle' | 'switch' - should be one of the values listed in `supported_commands` from the [`server/state`](#server--client-serverstate-controller-object) `controller` object. Commands not in `supported_commands` are ignored by the server + - `volume?`: integer - volume range 0-100, only set if `command` is `volume` + - `mute?`: boolean - true to mute, false to unmute, only set if `command` is `mute` + +#### Command behaviour + +- 'play' - resume playback from current position. If nothing is currently playing, the server must try to resume the group's last playing media. This history should persist across server and client reboots +- 'pause' - pause playback at current position +- 'stop' - stop playback and reset position to beginning +- 'next' - skip to next track, chapter, etc. +- 'previous' - skip to previous track, chapter, restart current, etc. +- 'volume' - set group volume (requires `volume` parameter) +- 'mute' - set group mute state (requires `mute` parameter) +- 'repeat_off' - disable repeat mode +- 'repeat_one' - repeat the current track continuously +- 'repeat_all' - repeat all tracks continuously +- 'shuffle' - randomize playback order +- 'unshuffle' - restore original playback order +- 'switch' - move this client to the next group in a predefined cycle as described [below](#switch-command-cycle) + +**Setting group volume:** When setting group volume via the 'volume' command, the server applies the following algorithm to preserve relative volume levels while achieving the requested volume as closely as player boundaries allow: + +1. Calculate the delta: `delta = requested_volume - current_group_volume` (where current group volume is the average of all player volumes) +2. Apply the delta to each player's volume +3. Clamp any player volumes that exceed boundaries (0-100%) +4. If any players were clamped: + - Calculate the lost delta: `sum of (proposed_volume - clamped_volume)` for all clamped players + - Divide the lost delta equally among non-clamped players + - Repeat steps 1-4 until either: + - All delta has been successfully applied, or + - All players are clamped at their volume boundaries + +This ensures that when setting group volume to 100%, all players will reach 100% if possible, and the final group volume matches the requested volume as closely as player boundaries allow. + +**Setting group mute:** When setting group mute via the 'mute' command, the server applies the mute state to all players in the group. + +#### Switch command cycle + +**Previous group priority:** If the client is still in the solo group from its `'external_source'` transition, the `switch` command prioritizes rejoining the previous group. + +For clients **with** the `player` role, the cycle includes: +1. Multi-client groups that are currently playing +2. Single-client groups (other players playing alone) +3. A solo group containing only this client + +For clients **without** the `player` role, the cycle includes: +1. Multi-client groups that are currently playing +2. Single-client groups (other players playing alone) + +### Server → Client: `server/state` controller object + +The `controller` object in [`server/state`](#server--client-serverstate) has this structure: + +- `controller`: object + - `supported_commands`: string[] - subset of: 'play' | 'pause' | 'stop' | 'next' | 'previous' | 'volume' | 'mute' | 'repeat_off' | 'repeat_one' | 'repeat_all' | 'shuffle' | 'unshuffle' | 'switch' + - `volume`: integer - volume of the whole group, range 0-100 + - `muted`: boolean - mute state of the whole group + - `repeat`: 'off' | 'one' | 'all' - repeat mode: 'off' = no repeat, 'one' = repeat current track, 'all' = repeat all tracks (in the queue, playlist, etc.) + - `shuffle`: boolean - shuffle mode enabled/disabled + +**Reading group volume:** Group volume is calculated as the average of all player volumes in the group. + +**Reading group mute:** Group mute is `true` only when all players in the group are muted. If some players are muted and others are not, group mute is `false`. + +## Metadata messages +This section describes messages specific to clients with the `metadata` role, which handle display of track information and playback progress. Metadata clients receive state updates with track details. + +### Server → Client: `server/state` metadata object + +The `metadata` object in [`server/state`](#server--client-serverstate) has this structure: + +- `metadata`: object + - `timestamp`: integer - server clock time in microseconds for when this metadata is valid + - `title?`: string | null - track title + - `artist?`: string | null - primary artist(s) + - `album_artist?`: string | null - album artist(s) + - `album?`: string | null - name of the album or release that this track belongs to + - `artwork_url?`: string | null - URL to artwork image. Useful for clients that want to forward metadata to external systems or for powerful clients that can fetch and process images themselves + - `year?`: integer | null - release year in YYYY format + - `track?`: integer | null - track number on the album (1-indexed), null if unknown or not applicable + - `progress?`: object | null - playback progress information. The server must send this object whenever playback state changes (play, pause, resume, seek, playback speed change) + - `track_progress`: integer - current playback position in milliseconds since start of track + - `track_duration`: integer - total track length in milliseconds, 0 for unlimited/unknown duration (e.g., live radio streams) + - `playback_speed`: integer - playback speed multiplier * 1000 (e.g., 1000 = normal speed, 1500 = 1.5x speed, 500 = 0.5x speed, 0 = paused) + +#### Calculating current track position + +Clients can calculate the current track position at any time using the `timestamp` and `progress` values from the last metadata message that included the `progress` object: + +```python +calculated_progress = metadata.progress.track_progress + (current_time - metadata.timestamp) * metadata.progress.playback_speed / 1000000 + +if metadata.progress.track_duration != 0: + current_track_progress_ms = max(min(calculated_progress, metadata.progress.track_duration), 0) +else: + current_track_progress_ms = max(calculated_progress, 0) +``` + +## Artwork messages +This section describes messages specific to clients with the `artwork` role, which handle display of artwork images. Artwork clients receive images in their preferred format and resolution. + +**Channels:** Artwork clients can support 1-4 independent channels, allowing them to display multiple related images. For example, a device could display album artwork on one channel while simultaneously showing artist photos or background images on other channels. Each channel operates independently with its own format, resolution, and source type (album or artist artwork). + +### Client → Server: `client/hello` artwork@v1 support object + +The `artwork@v1_support` object in [`client/hello`](#client--server-clienthello) has this structure: + +- `artwork@v1_support`: object + - `channels`: object[] - list of supported artwork channels (length 1-4), array index is the channel number + - `source`: 'album' | 'artist' | 'none' - artwork source type + - `format`: 'jpeg' | 'png' | 'bmp' - image format identifier + - `media_width`: integer - max width in pixels + - `media_height`: integer - max height in pixels + +**Note:** The server will scale images to fit within the specified dimensions while preserving aspect ratio. Clients can support 1-4 independent artwork channels depending on their display capabilities. The channel number is determined by array position: `channels[0]` is channel 0 (binary message type 8), `channels[1]` is channel 1 (binary message type 9), etc. + +**None source:** If a channel has `source` set to `none`, the server will not send any artwork data for that channel. This allows clients to disable and enable specific channels on the fly through [`stream/request-format`](#client--server-streamrequest-format-artwork-object) without needing to re-establish the WebSocket connection (useful for dynamic display layouts). + +**Note:** Servers must support all image formats: 'jpeg', 'png', and 'bmp'. + +### Client → Server: `stream/request-format` artwork object + +The `artwork` object in [`stream/request-format`](#client--server-streamrequest-format) has this structure: + +Request the server to change the artwork format for a specific channel. The client can send multiple `stream/request-format` messages to change formats on different channels. + +After receiving this message, the server responds with [`stream/start`](#server--client-streamstart) for the artwork role with the new format, followed by immediate artwork updates through binary messages. + +- `artwork`: object + - `channel`: integer - channel number (0-3) corresponding to the channel index declared in the artwork [`client/hello`](#client--server-clienthello-artworkv1-support-object) + - `source?`: 'album' | 'artist' | 'none' - artwork source type + - `format?`: 'jpeg' | 'png' | 'bmp' - requested image format identifier + - `media_width?`: integer - requested max width in pixels + - `media_height?`: integer - requested max height in pixels + +### Server → Client: `stream/start` artwork object + +The `artwork` object in [`stream/start`](#server--client-streamstart) has this structure: + +- `artwork`: object + - `channels`: object[] - configuration for each active artwork channel, array index is the channel number + - `source`: 'album' | 'artist' | 'none' - artwork source type + - `format`: 'jpeg' | 'png' | 'bmp' - format of the encoded image + - `width`: integer - width in pixels of the encoded image + - `height`: integer - height in pixels of the encoded image + +### Server → Client: Artwork (Binary) + +Binary messages should be rejected if there is no active stream. + +- Byte 0: message type `8`-`11` (uint8) - corresponds to artwork channel 0-3 respectively +- Bytes 1-8: timestamp (big-endian int64) - server clock time in microseconds when the image should be displayed by the device +- Rest of bytes: encoded image + +The message type determines which artwork channel this image is for: +- Type `8`: Channel 0 (Artwork role, slot 0) +- Type `9`: Channel 1 (Artwork role, slot 1) +- Type `10`: Channel 2 (Artwork role, slot 2) +- Type `11`: Channel 3 (Artwork role, slot 3) + +The timestamp indicates when this artwork should be displayed. Clients must translate this server timestamp to their local clock using the offset computed from clock synchronization. + +**Clearing artwork:** To clear the currently displayed artwork on a specific channel, the server sends an empty binary message (only the message type byte and timestamp, with no image data) for that channel. + +## Visualizer messages +This section describes messages specific to clients with the `visualizer` role, which create visual representations of the audio being played. Visualizer clients receive audio analysis data like FFT information that corresponds to the current audio timeline. + +### Client → Server: `client/hello` visualizer@v1 support object + +The `visualizer@v1_support` object in [`client/hello`](#client--server-clienthello) has this structure: + +- `visualizer@v1_support`: object + - Desired FFT details (to be determined) + - `buffer_capacity`: integer - max size in bytes of visualization data messages in the buffer that are yet to be displayed + +### Server → Client: `stream/start` visualizer object + +The `visualizer` object in [`stream/start`](#server--client-streamstart) has this structure: + +- `visualizer`: object + - FFT details (to be determined) + +### Server → Client: `stream/clear` visualizer + +When [`stream/clear`](#server--client-streamclear) includes the visualizer role, clients should clear all buffered visualization data and continue with data received after this message. + +### Server → Client: Visualization Data (Binary) + +Binary messages should be rejected if there is no active stream. + +- Byte 0: message type `16` (uint8) +- Bytes 1-8: timestamp (big-endian int64) - server clock time in microseconds when the visualization should be displayed by the device +- Rest of bytes: visualization data + +The timestamp indicates when this visualization data should be displayed, corresponding to the audio timeline. Clients must translate this server timestamp to their local clock using the offset computed from clock synchronization. + +## Color messages +This section describes messages specific to clients with the `color` role, which receive colors derived from the current audio. Colors may be extracted from album artwork, provided by the music source, or manually programmed by the server. + +### Server → Client: `server/state` color object + +The `color` object in [`server/state`](#server--client-serverstate) has this structure: + +- `color`: object + - `timestamp`: integer - server clock time in microseconds for when these colors are valid + - `background_dark?`: integer[] | null - background color suitable for dark mode as `[R, G, B]` with values 0-255. The server must ensure a minimum WCAG contrast ratio of 4.5:1 with white text and with `on_dark` (if also present). + - `background_light?`: integer[] | null - background color suitable for light mode as `[R, G, B]` with values 0-255. The server must ensure a minimum WCAG contrast ratio of 4.5:1 with black text and with `on_light` (if also present). + - `primary?`: integer[] | null - the dominant color, as `[R, G, B]` with values 0-255. Not adjusted for contrast. + - `accent?`: integer[] | null - a secondary or complementary color, as `[R, G, B]` with values 0-255. Not adjusted for contrast. + - `on_dark?`: integer[] | null - a light color suitable for use on dark backgrounds, as `[R, G, B]` with values 0-255 + - `on_light?`: integer[] | null - a dark color suitable for use on light backgrounds, as `[R, G, B]` with values 0-255 diff --git a/cmd/client/main.go b/cmd/client/main.go new file mode 100644 index 0000000..7a7f83e --- /dev/null +++ b/cmd/client/main.go @@ -0,0 +1,118 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "github.com/Sendspin/sendspin-go/pkg/sendspin" + + "rpi-sendspin/internal/pipewire" +) + +func main() { + server := flag.String("server", "localhost:8927", "server address") + name := flag.String("name", hostname(), "player name") + device := flag.String("device", "", "PipeWire sink node name (default: system default)") + listDevices := flag.Bool("list-devices", false, "list available PipeWire sinks and exit") + volume := flag.Int("volume", 80, "initial volume (0-100)") + flag.Parse() + + if *listDevices { + sinks, err := pipewire.ListSinks() + if err != nil { + log.Fatalf("list sinks: %v", err) + } + if len(sinks) == 0 { + fmt.Println("no PipeWire sinks found") + return + } + fmt.Println("Available PipeWire sinks:") + for _, s := range sinks { + fmt.Printf(" %s\n", s) + } + return + } + + idFile := clientIDFile() + savedID, _ := os.ReadFile(idFile) + clientID, err := sendspin.ResolveClientID("", string(savedID), func(id string) error { + if err := os.MkdirAll(filepath.Dir(idFile), 0700); err != nil { + return err + } + return os.WriteFile(idFile, []byte(id), 0600) + }) + if err != nil { + log.Fatalf("resolve client ID: %v", err) + } + + output := pipewire.NewOutput(*device, *name) + + player, err := sendspin.NewPlayer(sendspin.PlayerConfig{ + ServerAddr: *server, + PlayerName: *name, + ClientID: clientID, + Volume: *volume, + Output: output, + Reconnect: sendspin.ReconnectConfig{ + Enabled: true, + InitialDelay: 2 * time.Second, + MaxDelay: 30 * time.Second, + Multiplier: 2.0, + }, + OnMetadata: func(meta sendspin.Metadata) { + if meta.Artist != "" || meta.Title != "" { + log.Printf("now playing: %s — %s", meta.Artist, meta.Title) + } + }, + OnError: func(err error) { + log.Printf("error: %v", err) + }, + }) + if err != nil { + log.Fatalf("create player: %v", err) + } + defer player.Close() + + if *device != "" { + log.Printf("output: %s", *device) + } else { + log.Printf("output: PipeWire default sink") + } + log.Printf("connecting to %s as %q...", *server, *name) + + if err := player.Connect(); err != nil { + log.Fatalf("connect: %v", err) + } + if err := player.Play(); err != nil { + log.Fatalf("play: %v", err) + } + + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt, syscall.SIGTERM) + <-sig + + log.Println("stopping") + player.Stop() +} + +func hostname() string { + h, _ := os.Hostname() + if h == "" { + return "sendspin-player" + } + return h +} + +func clientIDFile() string { + dir, err := os.UserConfigDir() + if err != nil { + dir = os.TempDir() + } + return filepath.Join(dir, "rpi-sendspin", "client_id") +} diff --git a/cmd/live-server/main.go b/cmd/live-server/main.go new file mode 100644 index 0000000..761db57 --- /dev/null +++ b/cmd/live-server/main.go @@ -0,0 +1,200 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "path/filepath" + "sort" + "strconv" + "syscall" + "time" + + "github.com/Sendspin/sendspin-go/pkg/sendspin" + + "rpi-sendspin/internal/live" +) + +// flagSpec describes one user-facing option. The flag is registered +// under all names; help output (custom Usage below) groups them onto a +// single line so `-s, --source` reads as one option rather than two. +type flagSpec struct { + long string + short string // optional + usage string +} + +var specs []flagSpec + +func addStr(target *string, def, long, short, usage string) { + flag.StringVar(target, long, def, usage) + if short != "" { + flag.StringVar(target, short, def, usage) + } + specs = append(specs, flagSpec{long, short, usage}) +} + +func addInt(target *int, def int, long, short, usage string) { + flag.IntVar(target, long, def, usage) + if short != "" { + flag.IntVar(target, short, def, usage) + } + specs = append(specs, flagSpec{long, short, usage}) +} + +func main() { + var ( + port int + name string + httpPort int + configPath string + source string + mqttURL string + mqttUser string + mqttPass string + mqttBase string + mqttDiscovery string + rate int + channels int + ) + + addInt(&port, 8927, "port", "p", "Sendspin WebSocket port") + addStr(&name, "Live Audio", "name", "n", "advertised server name") + addInt(&httpPort, 8080, "http-port", "", "HTTP/API port") + addStr(&configPath, defaultConfigPath(), "config", "c", "presets file path") + addStr(&source, "", "source", "s", "PipeWire source node (e.g. alsa_input.usb-...pro-input-0.monitor); when set, overrides preset.source") + addStr(&mqttURL, "", "mqtt-url", "", "MQTT broker URL (e.g. tcp://broker.local:1883); empty disables MQTT") + addStr(&mqttUser, "", "mqtt-username", "", "MQTT username") + addStr(&mqttPass, "", "mqtt-password", "", "MQTT password") + addStr(&mqttBase, "sendspin/live", "mqtt-base", "", "MQTT base topic") + addStr(&mqttDiscovery, "homeassistant", "mqtt-discovery", "", "Home Assistant discovery prefix") + addInt(&rate, 48000, "rate", "r", "capture sample rate") + addInt(&channels, 2, "channels", "", "capture channels") + + flag.Usage = usage + flag.Parse() + + log.SetFlags(log.LstdFlags | log.Lmicroseconds) + + store, err := live.NewStore(configPath) + if err != nil { + log.Fatalf("load presets: %v", err) + } + + src := live.NewSource(rate, channels) + roster := live.NewRoster() + + var mgr *live.Manager + server, err := sendspin.NewServer(sendspin.ServerConfig{ + Port: port, + Name: name, + Source: src, + EnableMDNS: true, + DiscoverClients: true, + ClientFilter: func(id string) bool { + if mgr == nil { + return false + } + return mgr.ClientAllowed(id) + }, + OnClientHello: roster.Record, + }) + if err != nil { + log.Fatalf("create sendspin server: %v", err) + } + mgr = live.NewManager(store, server, src) + mgr.SetSourceOverride(source) + if source != "" { + log.Printf("source override: %s (preset.source ignored)", source) + } + + disc := live.NewDiscovery() + disc.Start() + defer disc.Stop() + + httpSrv := &http.Server{ + Addr: ":" + strconv.Itoa(httpPort), + Handler: live.NewHTTPServer(mgr, store, disc, roster, server).Handler(), + ReadTimeout: 5 * time.Second, + WriteTimeout: 10 * time.Second, + } + go func() { + log.Printf("HTTP API listening on %s", httpSrv.Addr) + if err := httpSrv.ListenAndServe(); err != http.ErrServerClosed { + log.Fatalf("http: %v", err) + } + }() + + var bridge *live.MQTTBridge + if mqttURL != "" { + bridge = live.NewMQTTBridge(live.MQTTConfig{ + URL: mqttURL, + Username: mqttUser, + Password: mqttPass, + BaseTopic: mqttBase, + DiscoveryPrefix: mqttDiscovery, + }, mgr) + if err := bridge.Start(); err != nil { + log.Fatalf("mqtt: %v", err) + } + } + + serverDone := make(chan error, 1) + go func() { serverDone <- server.Start() }() + log.Printf("Sendspin server %q on :%d", name, port) + + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt, syscall.SIGTERM) + select { + case <-sig: + log.Printf("signal received, shutting down") + case err := <-serverDone: + log.Printf("sendspin server exited: %v", err) + } + + if bridge != nil { + bridge.Stop() + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = httpSrv.Shutdown(ctx) + server.Stop() + _ = src.Close() +} + +func defaultConfigPath() string { + home, err := os.UserHomeDir() + if err != nil { + return "presets.json" + } + return filepath.Join(home, ".config", "sendspin-live", "presets.json") +} + +// usage renders one line per logical option, pairing the short alias +// with the long form (e.g. "-s, --source"). The default flag.Usage +// would list each registration separately and double-print everything. +func usage() { + out := flag.CommandLine.Output() + fmt.Fprintf(out, "Usage: %s [flags]\n\nFlags:\n", os.Args[0]) + + sort.Slice(specs, func(i, j int) bool { return specs[i].long < specs[j].long }) + + for _, s := range specs { + f := flag.Lookup(s.long) + if f == nil { + continue + } + head := " --" + s.long + if s.short != "" { + head = " -" + s.short + ", --" + s.long + } + if f.DefValue != "" { + head += "=" + f.DefValue + } + fmt.Fprintf(out, "%-44s %s\n", head, s.usage) + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..2954156 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,88 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + "os/signal" + "syscall" + + "github.com/Sendspin/sendspin-go/pkg/sendspin" + + "rpi-sendspin/internal/pipewire" +) + +func main() { + port := flag.Int("port", 8927, "listen port") + name := flag.String("name", "Sendspin Server", "server name") + source := flag.String("source", "", "PipeWire source node name") + listSources := flag.Bool("list-sources", false, "list available PipeWire sources and exit") + rate := flag.Int("rate", 48000, "capture sample rate") + channels := flag.Int("channels", 2, "capture channels") + flag.Parse() + + if *listSources { + printSources() + return + } + + if *source == "" { + sources, err := pipewire.ListSources() + if err != nil { + log.Fatalf("list sources: %v", err) + } + if len(sources) == 0 { + log.Fatal("no PipeWire sources found; is PipeWire running?") + } + + fmt.Println("Available PipeWire sources:") + for i, s := range sources { + fmt.Printf(" %d. %s\n", i+1, s) + } + fmt.Printf("\nSpecify a source with --source \n") + fmt.Printf("Example: --source %s\n", sources[0].Name) + os.Exit(1) + } + + src := pipewire.NewSource(*source, *rate, *channels) + + log.Printf("capturing from %q at %d Hz %d ch", *source, *rate, *channels) + + server, err := sendspin.NewServer(sendspin.ServerConfig{ + Port: *port, + Name: *name, + Source: src, + }) + if err != nil { + log.Fatalf("create server: %v", err) + } + + if err := server.Start(); err != nil { + log.Fatalf("start server: %v", err) + } + log.Printf("server %q listening on :%d", *name, *port) + + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt, syscall.SIGTERM) + <-sig + + log.Println("stopping") + server.Stop() + src.Close() +} + +func printSources() { + sources, err := pipewire.ListSources() + if err != nil { + log.Fatalf("list sources: %v", err) + } + if len(sources) == 0 { + fmt.Println("no PipeWire sources found") + return + } + fmt.Println("Available PipeWire sources:") + for _, s := range sources { + fmt.Printf(" %s\n", s) + } +} diff --git a/dist/systemd/sendspin-client.service b/dist/systemd/sendspin-client.service new file mode 100644 index 0000000..aad7841 --- /dev/null +++ b/dist/systemd/sendspin-client.service @@ -0,0 +1,16 @@ +[Unit] +Description=Sendspin audio player +After=pipewire.service network-online.target +Wants=pipewire.service + +[Service] +ExecStart=%h/.local/bin/sendspin-client \ + --server 192.168.1.100:8927 \ + --name %H \ + --device alsa_output.usb-Device-00.analog-stereo \ + --volume 80 +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=default.target diff --git a/dist/systemd/sendspin-server.service b/dist/systemd/sendspin-server.service new file mode 100644 index 0000000..b894cb9 --- /dev/null +++ b/dist/systemd/sendspin-server.service @@ -0,0 +1,17 @@ +[Unit] +Description=Sendspin audio streaming server +After=pipewire.service network-online.target +Wants=pipewire.service + +[Service] +ExecStart=%h/.local/bin/sendspin-server \ + --source alsa_input.usb-Device-00.analog-stereo \ + --name %H \ + --port 8927 \ + --rate 48000 \ + --channels 2 +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=default.target diff --git a/esphome/speaker.yaml b/esphome/speaker.yaml new file mode 100644 index 0000000..7677110 --- /dev/null +++ b/esphome/speaker.yaml @@ -0,0 +1,162 @@ +substitutions: + name: "audio-test" + friendly_name: "Audio Test" + # AP fallback password (8+ chars) + ap_password: "SYOMYqzKWbfl" + +esphome: + name: ${name} + friendly_name: ${friendly_name} + on_boot: + priority: 600.0 + then: + # PCM5102 SCK pin is tied to GND on the breakout; this GPIO is also + # soldered to that node, so we hold it LOW to avoid driving against GND. + - output.turn_off: sck_pin_low + +esp32: + board: esp32-s3-devkitc-1 + framework: + type: esp-idf + +psram: + mode: quad + speed: 80MHz + +logger: + +api: + encryption: + key: !secret api_encryption_key + +ota: + - platform: esphome + password: !secret ota_password + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + power_save_mode: none + ap: + ssid: "${friendly_name} Fallback" + password: ${ap_password} + +captive_portal: + +http_request: + timeout: 10s + verify_ssl: false + +output: + - platform: gpio + pin: GPIO15 + id: sck_pin_low + +i2s_audio: + id: i2s_bus + i2s_lrclk_pin: GPIO18 # A0 -> LCK + i2s_bclk_pin: GPIO16 # A2 -> BCK + +speaker: + - platform: i2s_audio + id: pcm5102_dac + i2s_audio_id: i2s_bus + i2s_dout_pin: GPIO17 # A1 -> DIN + dac_type: external + sample_rate: 48000 + bits_per_sample: 16bit + channel: stereo + timeout: never + buffer_duration: 100ms + + - platform: mixer + id: audio_mixer + output_speaker: pcm5102_dac + num_channels: 2 + task_stack_in_psram: true + source_speakers: + - id: announcement_mixer_input + timeout: never + - id: media_mixer_input + timeout: never + + - platform: resampler + id: media_resampler + output_speaker: media_mixer_input + sample_rate: 48000 + bits_per_sample: 16 + - platform: resampler + id: announcement_resampler + output_speaker: announcement_mixer_input + sample_rate: 48000 + bits_per_sample: 16 + +sendspin: + id: sendspin_hub + task_stack_in_psram: false + +media_source: + - platform: http_request + id: http_media_source + buffer_size: 500000 + - platform: http_request + id: http_announcement_source + buffer_size: 250000 + - platform: sendspin + id: sendspin_media_source + +media_player: + - platform: sendspin + id: sendspin_group_media_player + + - platform: speaker_source + id: external_media_player + name: "Media Player" + media_pipeline: + format: FLAC + num_channels: 2 + sample_rate: 48000 + speaker: media_resampler + sources: + - http_media_source + - sendspin_media_source + announcement_pipeline: + format: FLAC + num_channels: 1 + sample_rate: 48000 + speaker: announcement_resampler + sources: + - http_announcement_source + on_mute: + - mixer_speaker.apply_ducking: + id: media_mixer_input + decibel_reduction: 51 + duration: 0.4s + - mixer_speaker.apply_ducking: + id: announcement_mixer_input + decibel_reduction: 51 + duration: 0.4s + on_unmute: + - mixer_speaker.apply_ducking: + id: media_mixer_input + decibel_reduction: 0 + duration: 0.4s + - mixer_speaker.apply_ducking: + id: announcement_mixer_input + decibel_reduction: 0 + duration: 0.4s + +external_components: + - source: + # https://github.com/esphome/esphome/pull/14933 + type: git + url: https://github.com/kahrendt/esphome + ref: 7a6cf5c8472b7e2fa18ee0fc314f66a80d249e32 + components: [const, media_source, sendspin] + - source: + # https://github.com/esphome/esphome/pull/12429 + type: git + url: https://github.com/esphome/esphome + ref: ff8ce89556748509d7ee8724e12d9d43d3c8c1e8 + refresh: 0s + components: [http_request] diff --git a/file.wav b/file.wav new file mode 100644 index 0000000000000000000000000000000000000000..ae96c82be8d924e7ed764aa0e9627b036b166485 GIT binary patch literal 1966080 zcmeIuF#!Mo0K%a4Pi+hzh(KY$fB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd Q0RsjM7%*VKfPv4z00F`P0RR91 literal 0 HcmV?d00001 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cc1bf50 --- /dev/null +++ b/go.mod @@ -0,0 +1,46 @@ +module rpi-sendspin + +go 1.24.1 + +require github.com/Sendspin/sendspin-go v1.7.0 + +replace github.com/Sendspin/sendspin-go => ./third_party/sendspin-go + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/eclipse/paho.mqtt.golang v1.5.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/gen2brain/malgo v0.11.24 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/hajimehoshi/go-mp3 v0.3.4 // indirect + github.com/hashicorp/mdns v1.0.6 // indirect + github.com/icza/bitio v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mewkiz/flac v1.0.13 // indirect + github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d // indirect + github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 // indirect + github.com/miekg/dns v1.1.55 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/mod v0.27.0 // indirect + golang.org/x/net v0.44.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.29.0 // indirect + golang.org/x/tools v0.36.0 // indirect + gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0afa90b --- /dev/null +++ b/go.sum @@ -0,0 +1,164 @@ +github.com/Sendspin/sendspin-go v1.7.0 h1:fd91KXj3TRINUp0YONCljUnNbTr4xIlTz6RXzv4aQH8= +github.com/Sendspin/sendspin-go v1.7.0/go.mod h1:h+MBsD/sFDZ/BdUrSvs07WRD79VluRDzvOl5RymlXoA= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE= +github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/gen2brain/malgo v0.11.24 h1:hHcIJVfzWcEDHFdPl5Dl/CUSOjzOleY0zzAV8Kx+imE= +github.com/gen2brain/malgo v0.11.24/go.mod h1:f9TtuN7DVrXMiV/yIceMeWpvanyVzJQMlBecJFVMxww= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68= +github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo= +github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo= +github.com/hashicorp/mdns v1.0.6 h1:SV8UcjnQ/+C7KeJ/QeVD/mdN2EmzYfcGfufcuzxfCLQ= +github.com/hashicorp/mdns v1.0.6/go.mod h1:X4+yWh+upFECLOki1doUPaKpgNQII9gy4bUdCYKNhmM= +github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0= +github.com/icza/bitio v1.1.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A= +github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k= +github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mewkiz/flac v1.0.13 h1:6wF8rRQKBFW159Daqx6Ro7K5ZnlVhHUKfS5aTsC4oXs= +github.com/mewkiz/flac v1.0.13/go.mod h1:HfPYDA+oxjyuqMu2V+cyKcxF51KM6incpw5eZXmfA6k= +github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d h1:IL2tii4jXLdhCeQN69HNzYYW1kl0meSG0wt5+sLwszU= +github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d/go.mod h1:SIpumAnUWSy0q9RzKD3pyH3g1t5vdawUAPcW5tQrUtI= +github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 h1:h8O1byDZ1uk6RUXMhj1QJU3VXFKXHDZxr4TXRPGeBa8= +github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985/go.mod h1:uiPmbdUbdt1NkGApKl7htQjZ8S7XaGUAVulJUJ9v6q4= +github.com/miekg/dns v1.1.55 h1:GoQ4hpsj0nFLYe+bWiCToyrBEJXkQfOOIvFGFy0lEgo= +github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= +golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302 h1:xeVptzkP8BuJhoIjNizd2bRHfq9KB9HfOLZu90T04XM= +gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302/go.mod h1:/L5E7a21VWl8DeuCPKxQBdVG5cy+L0MRZ08B1wnqt7g= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/home-assistant-voice.yaml b/home-assistant-voice.yaml new file mode 100644 index 0000000..43f9e72 --- /dev/null +++ b/home-assistant-voice.yaml @@ -0,0 +1,1924 @@ +substitutions: + # Phases of the Voice Assistant + # The voice assistant is ready to be triggered by a wake word + voice_assist_idle_phase_id: '1' + # The voice assistant is waiting for a voice command (after being triggered by the wake word) + voice_assist_waiting_for_command_phase_id: '2' + # The voice assistant is listening for a voice command + voice_assist_listening_for_command_phase_id: '3' + # The voice assistant is currently processing the command + voice_assist_thinking_phase_id: '4' + # The voice assistant is replying to the command + voice_assist_replying_phase_id: '5' + # The voice assistant is not ready + voice_assist_not_ready_phase_id: '10' + # The voice assistant encountered an error + voice_assist_error_phase_id: '11' + # Change this to true in case you ahve a hidden SSID at home. + hidden_ssid: "false" + # Substitutions for audio files + jack_connected_sound_file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/jack_connected.flac + jack_disconnected_sound_file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/jack_disconnected.flac + mute_switch_on_sound_file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/mute_switch_on.flac + mute_switch_off_sound_file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/mute_switch_off.flac + timer_finished_sound_file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/timer_finished.flac + wake_word_triggered_sound_file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/wake_word_triggered.flac + center_button_press_sound_file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/center_button_press.flac + center_button_double_press_sound_file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/center_button_double_press.flac + center_button_triple_press_sound_file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/center_button_triple_press.flac + center_button_long_press_sound_file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/center_button_long_press.flac + factory_reset_initiated_sound_file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/factory_reset_initiated.mp3 + factory_reset_cancelled_sound_file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/factory_reset_cancelled.mp3 + factory_reset_confirmed_sound_file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/factory_reset_confirmed.mp3 + easter_egg_tick_sound_file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/easter_egg_tick.mp3 + easter_egg_tada_sound_file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/easter_egg_tada.mp3 + error_cloud_expired_sound_file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/error_cloud_expired.mp3 + +esphome: + name: home-assistant-voice + friendly_name: Home Assistant Voice + name_add_mac_suffix: true + min_version: 2026.3.0 + on_boot: + priority: 375 + then: + # Run the script to refresh the LED status + - script.execute: control_leds + - delay: 1s + - switch.turn_on: internal_speaker_amp + # If after 10 minutes, the device is still initializing (It did not yet connect to Home Assistant), turn off the init_in_progress variable and run the script to refresh the LED status + - delay: 10min + - if: + condition: + lambda: return id(init_in_progress); + then: + - lambda: id(init_in_progress) = false; + - script.execute: control_leds + +esp32: + board: esp32-s3-devkitc-1 + cpu_frequency: 240MHz + variant: esp32s3 + flash_size: 16MB + framework: + type: esp-idf + version: recommended + sdkconfig_options: + CONFIG_ESP32S3_DATA_CACHE_64KB: "y" + CONFIG_ESP32S3_DATA_CACHE_LINE_64B: "y" + CONFIG_ESP32S3_INSTRUCTION_CACHE_32KB: "y" + + # Moves instructions and read only data from flash into PSRAM on boot. + # Both enabled allows instructions to execute while a flash operation is in progress without needing to be placed in IRAM. + # Considerably speeds up mWW at the cost of using more PSRAM. + CONFIG_SPIRAM_RODATA: "y" + CONFIG_SPIRAM_FETCH_INSTRUCTIONS: "y" + + CONFIG_BT_ALLOCATION_FROM_SPIRAM_FIRST: "y" + CONFIG_BT_BLE_DYNAMIC_ENV_MEMORY: "y" + + CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC: "y" + CONFIG_MBEDTLS_SSL_PROTO_TLS1_3: "y" # TLS1.3 support isn't enabled by default in IDF 5.1.5 + +wifi: + id: wifi_id + fast_connect: ${hidden_ssid} + on_connect: + - lambda: id(improv_ble_in_progress) = false; + - script.execute: control_leds + # Enable/disable Sendspin static adjustment delay if the jack is plugged in or not + - if: + condition: + - binary_sensor.is_on: jack_plugged + then: + - sendspin.media_source.enable_static_delay_adjustment: + else: + - sendspin.media_source.disable_static_delay_adjustment: + on_disconnect: + - script.execute: control_leds + +network: + enable_ipv6: true + +logger: + level: DEBUG + logs: + sensor: WARN # avoids logging debug sensor updates + +api: + id: api_id + on_client_connected: + - script.execute: control_leds + on_client_disconnected: + - script.execute: control_leds + encryption: # Uses key set by Home Assistant + +ota: + - platform: esphome + id: ota_esphome + +i2c: + - id: internal_i2c + sda: GPIO5 + scl: GPIO6 + frequency: 400kHz + +psram: + mode: octal + speed: 80MHz + ignore_not_found: false # The VPE has PSRAM, so this is safe. Allows configuring WiFi driver to use more resources (done automatically by the speaker media player) + +globals: + # Global index for our LEDs. So that switching between different animation does not lead to unwanted effects. + - id: global_led_animation_index + type: int + restore_value: no + initial_value: '0' + # Global initialization variable. Initialized to true and set to false once everything is connected. Only used to have a smooth "plugging" experience + - id: init_in_progress + type: bool + restore_value: no + initial_value: 'true' + # Global variable storing the state of ImprovBLE. Used to draw different LED animations + - id: improv_ble_in_progress + type: bool + restore_value: no + initial_value: 'false' + # Global variable tracking the phase of the voice assistant (defined above). Initialized to not_ready + - id: voice_assistant_phase + type: int + restore_value: no + initial_value: ${voice_assist_not_ready_phase_id} + # Global variable tracking if the dial was recently touched. + - id: dial_touched + type: bool + restore_value: no + initial_value: 'false' + # Global variable tracking if the LED color was recently changed. + - id: color_changed + type: bool + restore_value: no + initial_value: 'false' + # Global variable tracking if the group media player volume was recent changed. + - id: group_volume_changed + type: bool + restore_value: no + initial_value: 'false' + # Global variable tracking if the jack has been plugged touched. + - id: jack_plugged_recently + type: bool + restore_value: no + initial_value: 'false' + # Global variable tracking if the jack has been unplugged touched. + - id: jack_unplugged_recently + type: bool + restore_value: no + initial_value: 'false' + # Global variable storing the first active timer + - id: first_active_timer + type: voice_assistant::Timer + restore_value: false + # Global variable storing if a timer is active + - id: is_timer_active + type: bool + restore_value: false + # Global variable storing if a factory reset was requested. If it is set to true, the device will factory reset once the center button is released + - id: factory_reset_requested + type: bool + restore_value: no + initial_value: 'false' + +switch: + # This is the master mute switch. It is exposed to Home Assistant. The user can only turn it on and off if the hardware switch is off. (The hardware switch overrides the software one) + - platform: template + id: master_mute_switch + restore_mode: RESTORE_DEFAULT_OFF + icon: "mdi:microphone-off" + name: Mute + entity_category: config + lambda: |- + // Muted either if the hardware mute switch is on or the microphone's software mute switch is enabled + if (id(hardware_mute_switch).state || id(i2s_mics).get_mute_state()) { + return true; + } else { + return false; + } + turn_on_action: + - if: + condition: + binary_sensor.is_off: hardware_mute_switch + then: + - microphone.mute: + turn_off_action: + - if: + condition: + binary_sensor.is_off: hardware_mute_switch + then: + - microphone.unmute: + on_turn_on: + - script.execute: control_leds + on_turn_off: + - script.execute: control_leds + # Wake Word Sound Switch. + - platform: template + id: wake_sound + name: Wake sound + icon: "mdi:bullhorn" + entity_category: config + optimistic: true + restore_mode: RESTORE_DEFAULT_ON + # Internal switch to track when a timer is ringing on the device. + - platform: template + id: timer_ringing + optimistic: true + internal: true + restore_mode: ALWAYS_OFF + on_turn_off: + # Disable stop wake word + - micro_wake_word.disable_model: stop + - script.execute: disable_repeat + # Stop any current announcement (ie: stop the timer ring mid playback) + - if: + condition: + media_player.is_announcing: + id: external_media_player + then: + media_player.stop: + announcement: true + id: external_media_player + # Set back ducking ratio to zero + - mixer_speaker.apply_ducking: + id: media_mixing_input + decibel_reduction: 0 + duration: 1.0s + # Refresh the LED ring + - script.execute: control_leds + on_turn_on: + # Duck audio + - mixer_speaker.apply_ducking: + id: media_mixing_input + decibel_reduction: 20 + duration: 0.0s + # Enable stop wake word + - micro_wake_word.enable_model: stop + # Ring timer + - script.execute: ring_timer + # Refresh LED + - script.execute: control_leds + # If 15 minutes have passed and the timer is still ringing, stop it. + - delay: 15min + - switch.turn_off: timer_ringing + - platform: gpio + pin: GPIO47 + id: internal_speaker_amp + name: "Internal speaker amp" + entity_category: config + restore_mode: ALWAYS_OFF + internal: true + +binary_sensor: + # Center Button. Used for many things (See on_multi_click) + - platform: gpio + id: center_button + pin: + number: GPIO0 + inverted: true + on_press: + - script.execute: control_leds + on_release: + - script.execute: control_leds + # If a factory reset is requested, factory reset on release + - if: + condition: + lambda: return id(factory_reset_requested); + then: + - button.press: factory_reset_button + on_multi_click: + # Simple Click: + # - Abort "things" in order + # - Timer + # - Announcements + # - Voice Assistant Pipeline run + # - Music + # - Starts the voice assistant if it is not yet running and if the device is not muted. + - timing: + - ON for at most 1s + - OFF for at least 0.25s + then: + - if: + condition: + lambda: return !id(init_in_progress) && !id(color_changed) && !id(group_volume_changed); + then: + - if: + condition: + switch.is_on: timer_ringing + then: + - switch.turn_off: timer_ringing + else: + - if: + condition: + voice_assistant.is_running: + then: + - voice_assistant.stop: + else: + - if: + condition: + media_player.is_announcing: + id: external_media_player + then: + media_player.stop: + announcement: true + id: external_media_player + else: + - if: + condition: + media_player.is_playing: + id: external_media_player + then: + - media_player.pause: + id: external_media_player + else: + - if: + condition: + and: + - switch.is_off: master_mute_switch + - not: voice_assistant.is_running + then: + - script.execute: + id: play_sound + priority: true + sound_file: "center_button_press_sound" + - delay: 300ms + - voice_assistant.start: + # Double Click + # . Exposed as an event entity. To be used in automations inside Home Assistant + - timing: + - ON for at most 1s + - OFF for at most 0.25s + - ON for at most 1s + - OFF for at least 0.25s + then: + - if: + condition: + lambda: return !id(init_in_progress) && !id(color_changed) && !id(group_volume_changed); + then: + - script.execute: + id: play_sound + priority: false + sound_file: "center_button_double_press_sound" + - event.trigger: + id: button_press_event + event_type: "double_press" + # Triple Click + # . Exposed as an event entity. To be used in automations inside Home Assistant + - timing: + - ON for at most 1s + - OFF for at most 0.25s + - ON for at most 1s + - OFF for at most 0.25s + - ON for at most 1s + - OFF for at least 0.25s + then: + - if: + condition: + lambda: return !id(init_in_progress) && !id(color_changed) && !id(group_volume_changed); + then: + - script.execute: + id: play_sound + priority: false + sound_file: "center_button_triple_press_sound" + - event.trigger: + id: button_press_event + event_type: "triple_press" + # Long Press + # . Exposed as an event entity. To be used in automations inside Home Assistant + - timing: + - ON for at least 1s + then: + - if: + condition: + lambda: return !id(init_in_progress) && !id(color_changed) && !id(group_volume_changed); + then: + - script.execute: + id: play_sound + priority: false + sound_file: "center_button_long_press_sound" + - light.turn_off: voice_assistant_leds + - event.trigger: + id: button_press_event + event_type: "long_press" + # Very important do not remove. Trust me :D + - timing: + # H .... + - ON for at most 0.2s + - OFF for 0s to 2s + - ON for at most 0.2s + - OFF for 0s to 2s + - ON for at most 0.2s + - OFF for 0s to 2s + - ON for at most 0.2s + - OFF for 0.5s to 2s + # A ._ + - ON for at most 0.2s + - OFF for 0s to 2s + - ON for 0.2s to 2s + then: + - if: + condition: + lambda: return !id(init_in_progress); + then: + - light.turn_on: + brightness: 100% + id: voice_assistant_leds + effect: "Tick" + - script.execute: + id: play_sound + priority: true + sound_file: "easter_egg_tick_sound" + - delay: 4s + - light.turn_off: voice_assistant_leds + - script.execute: + id: play_sound + priority: true + sound_file: "easter_egg_tada_sound" + - light.turn_on: + brightness: 100% + id: voice_assistant_leds + effect: "Rainbow" + - event.trigger: + id: button_press_event + event_type: "easter_egg_press" + # Factory Reset Warning + # . Audible and Visible warning. + - timing: + - ON for at least 10s + then: + - if: + condition: + lambda: return !id(dial_touched); + then: + - light.turn_on: + brightness: 100% + id: voice_assistant_leds + effect: "Factory Reset Coming Up" + - script.execute: + id: play_sound + priority: true + sound_file: "factory_reset_initiated_sound" + - wait_until: + binary_sensor.is_off: center_button + - if: + condition: + lambda: return !id(factory_reset_requested); + then: + - light.turn_off: voice_assistant_leds + - script.execute: + id: play_sound + priority: true + sound_file: "factory_reset_cancelled_sound" + # Factory Reset Confirmed. + # . Audible warning to prompt user to release the button + # . Set factory_reset_requested to true + - timing: + - ON for at least 22s + then: + - if: + condition: + lambda: return !id(dial_touched); + then: + - script.execute: + id: play_sound + priority: true + sound_file: "factory_reset_confirmed_sound" + - light.turn_on: + brightness: 100% + red: 100% + green: 0% + blue: 0% + id: voice_assistant_leds + effect: "none" + - lambda: id(factory_reset_requested) = true; + + # Hardware mute switch (Side of the device) + - platform: gpio + id: hardware_mute_switch + internal: true + pin: GPIO3 + on_press: + # Play mute on sound only if software mute isn't enabled + - if: + condition: + - switch.is_off: master_mute_switch + then: + - script.execute: + id: play_sound + priority: false + sound_file: "mute_switch_on_sound" + on_release: + - script.execute: + id: play_sound + priority: false + sound_file: "mute_switch_off_sound" + - microphone.unmute: + # Audio Jack Plugged sensor + - platform: gpio + id: jack_plugged + # Debouncing it a bit because it can be activated back and forth as you plug the audio jack + filters: + - delayed_on: 200ms + - delayed_off: 200ms + pin: + number: GPIO17 + # When the jack is plugged in: + # - LED animation + # - Enable Sendspin static delay adjustment to apply the persisted delay when using the external audio output + # - Sound played + on_press: + - lambda: id(jack_plugged_recently) = true; + - sendspin.media_source.enable_static_delay_adjustment: + - script.execute: control_leds + - delay: 200ms + - script.execute: + id: play_sound + priority: false + sound_file: "jack_connected_sound" + - delay: 800ms + - lambda: id(jack_plugged_recently) = false; + - script.execute: control_leds + # When the jack is unplugged: + # - LED animation + # - Disable Sendspin static delay adjustment when using the internal speaker + # - Sound played + on_release: + - lambda: id(jack_unplugged_recently) = true; + - script.execute: control_leds + - sendspin.media_source.disable_static_delay_adjustment: + - delay: 200ms + - script.execute: + id: play_sound + priority: false + sound_file: "jack_disconnected_sound" + - delay: 800ms + - lambda: id(jack_unplugged_recently) = false; + - script.execute: control_leds + +light: + # Hardware LED ring. Not used because remapping needed + - platform: esp32_rmt_led_strip + id: leds_internal + pin: GPIO21 + chipset: WS2812 + max_refresh_rate: 15ms + num_leds: 12 + rgb_order: GRB + rmt_symbols: 192 + default_transition_length: 0ms + power_supply: led_power + + # Voice Assistant LED ring. Remapping of the internal LED. + # This light is not exposed. The device controls it + - platform: partition + id: voice_assistant_leds + internal: true + default_transition_length: 0ms + segments: + - id: leds_internal + from: 7 + to: 11 + - id: leds_internal + from: 0 + to: 6 + effects: + - addressable_lambda: + name: "Waiting for Command" + update_interval: 100ms + lambda: |- + auto light_color = id(led_ring).current_values; + Color color(light_color.get_red() * 255, light_color.get_green() * 255, + light_color.get_blue() * 255); + for (uint8_t i = 0; i < 12; i++) { + if (i == id(global_led_animation_index) % 12) { + it[i] = color; + } else if (i == (id(global_led_animation_index) + 11) % 12) { + it[i] = color * 192; + } else if (i == (id(global_led_animation_index) + 10) % 12) { + it[i] = color * 128; + } else if (i == (id(global_led_animation_index) + 6) % 12) { + it[i] = color; + } else if (i == (id(global_led_animation_index) + 5) % 12) { + it[i] = color * 192; + } else if (i == (id(global_led_animation_index) + 4) % 12) { + it[i] = color * 128; + } else { + it[i] = Color::BLACK; + } + } + id(global_led_animation_index) = (id(global_led_animation_index) + 1) % 12; + - addressable_lambda: + name: "Listening For Command" + update_interval: 50ms + lambda: |- + auto light_color = id(led_ring).current_values; + Color color(light_color.get_red() * 255, light_color.get_green() * 255, + light_color.get_blue() * 255); + for (uint8_t i = 0; i < 12; i++) { + if (i == id(global_led_animation_index) % 12) { + it[i] = color; + } else if (i == (id(global_led_animation_index) + 11) % 12) { + it[i] = color * 192; + } else if (i == (id(global_led_animation_index) + 10) % 12) { + it[i] = color * 128; + } else if (i == (id(global_led_animation_index) + 6) % 12) { + it[i] = color; + } else if (i == (id(global_led_animation_index) + 5) % 12) { + it[i] = color * 192; + } else if (i == (id(global_led_animation_index) + 4) % 12) { + it[i] = color * 128; + } else { + it[i] = Color::BLACK; + } + } + id(global_led_animation_index) = (id(global_led_animation_index) + 1) % 12; + - addressable_lambda: + name: "Thinking" + update_interval: 10ms + lambda: |- + static uint8_t brightness_step = 0; + static bool brightness_decreasing = true; + static uint8_t brightness_step_number = 10; + if (initial_run) { + brightness_step = 0; + brightness_decreasing = true; + } + auto light_color = id(led_ring).current_values; + Color color(light_color.get_red() * 255, light_color.get_green() * 255, + light_color.get_blue() * 255); + for (uint8_t i = 0; i < 12; i++) { + if (i == id(global_led_animation_index) % 12) { + it[i] = color * uint8_t(255/brightness_step_number*(brightness_step_number-brightness_step)); + } else if (i == (id(global_led_animation_index) + 6) % 12) { + it[i] = color * uint8_t(255/brightness_step_number*(brightness_step_number-brightness_step)); + } else { + it[i] = Color::BLACK; + } + } + if (brightness_decreasing) { + brightness_step++; + } else { + brightness_step--; + } + if (brightness_step == 0 || brightness_step == brightness_step_number) { + brightness_decreasing = !brightness_decreasing; + } + - addressable_lambda: + name: "Replying" + update_interval: 50ms + lambda: |- + id(global_led_animation_index) = (12 + id(global_led_animation_index) - 1) % 12; + auto light_color = id(led_ring).current_values; + Color color(light_color.get_red() * 255, light_color.get_green() * 255, + light_color.get_blue() * 255); + for (uint8_t i = 0; i < 12; i++) { + if (i == (id(global_led_animation_index)) % 12) { + it[i] = color; + } else if (i == ( id(global_led_animation_index) + 1) % 12) { + it[i] = color * 192; + } else if (i == ( id(global_led_animation_index) + 2) % 12) { + it[i] = color * 128; + } else if (i == ( id(global_led_animation_index) + 6) % 12) { + it[i] = color; + } else if (i == ( id(global_led_animation_index) + 7) % 12) { + it[i] = color * 192; + } else if (i == ( id(global_led_animation_index) + 8) % 12) { + it[i] = color * 128; + } else { + it[i] = Color::BLACK; + } + } + - addressable_lambda: + name: "Muted or Silent" + update_interval: 16ms + lambda: |- + static int8_t index = 0; + Color muted_color(255, 0, 0); + auto light_color = id(led_ring).current_values; + Color color(light_color.get_red() * 255, light_color.get_green() * 255, + light_color.get_blue() * 255); + for (uint8_t i = 0; i < 12; i++) { + if ( light_color.get_state() ) { + it[i] = color; + } else { + it[i] = Color::BLACK; + } + } + if ( id(master_mute_switch).state ) { + it[2] = Color::BLACK; + it[3] = muted_color; + it[4] = Color::BLACK; + it[8] = Color::BLACK; + it[9] = muted_color; + it[10] = Color::BLACK; + } + if ( id(external_media_player).volume == 0.0f || id(external_media_player).is_muted() ) { + it[5] = Color::BLACK; + it[6] = muted_color; + it[7] = Color::BLACK; + } + - addressable_lambda: + name: "Voice kit startup failed" + # update_interval: 16ms + lambda: |- + static int8_t index = 0; + Color fail_color(255, 0, 0); + for (uint8_t i = 0; i < 12; i++) { + if (i % 3) { + it[i] = Color::BLACK; + } else { + it[i] = fail_color; + } + } + - addressable_lambda: + name: "Volume Display" + update_interval: 50ms + lambda: |- + auto light_color = id(led_ring).current_values; + Color color(light_color.get_red() * 255, light_color.get_green() * 255, + light_color.get_blue() * 255); + Color silenced_color(255, 0, 0); + auto volume_ratio = 12.0f * id(external_media_player).volume; + for (uint8_t i = 0; i < 12; i++) { + if (i <= volume_ratio) { + it[(6+i)%12] = color * min( 255.0f * (volume_ratio - i) , 255.0f ) ; + } else { + it[(6+i)%12] = Color::BLACK; + } + } + if (id(external_media_player).volume == 0.0f) { + it[6] = silenced_color; + } + - addressable_lambda: + name: "Center Button Touched" + update_interval: 16ms + lambda: |- + if (initial_run) { + // set voice_assistant_leds light to colors based on led_ring + auto led_ring_cv = id(led_ring).current_values; + auto va_leds_call = id(voice_assistant_leds).make_call(); + va_leds_call.from_light_color_values(led_ring_cv); + va_leds_call.set_brightness( min ( max( id(led_ring).current_values.get_brightness() , 0.2f ) + 0.1f , 1.0f ) ); + va_leds_call.set_state(true); + va_leds_call.perform(); + } + auto light_color = id(voice_assistant_leds).current_values; + Color color(light_color.get_red() * 255, light_color.get_green() * 255, + light_color.get_blue() * 255); + for (uint8_t i = 0; i < 12; i++) { + it[i] = color; + } + - addressable_twinkle: + name: "Twinkle" + twinkle_probability: 50% + - addressable_lambda: + name: "Error" + update_interval: 10ms + lambda: |- + static uint8_t brightness_step = 0; + static bool brightness_decreasing = true; + static uint8_t brightness_step_number = 10; + if (initial_run) { + brightness_step = 0; + brightness_decreasing = true; + } + Color error_color(255, 0, 0); + for (uint8_t i = 0; i < 12; i++) { + it[i] = error_color * uint8_t(255/brightness_step_number*(brightness_step_number-brightness_step)); + } + if (brightness_decreasing) { + brightness_step++; + } else { + brightness_step--; + } + if (brightness_step == 0 || brightness_step == brightness_step_number) { + brightness_decreasing = !brightness_decreasing; + } + - addressable_lambda: + name: "Timer Ring" + update_interval: 10ms + lambda: |- + static uint8_t brightness_step = 0; + static bool brightness_decreasing = true; + static uint8_t brightness_step_number = 10; + if (initial_run) { + brightness_step = 0; + brightness_decreasing = true; + } + auto light_color = id(led_ring).current_values; + Color color(light_color.get_red() * 255, light_color.get_green() * 255, + light_color.get_blue() * 255); + Color muted_color(255, 0, 0); + for (uint8_t i = 0; i < 12; i++) { + it[i] = color * uint8_t(255/brightness_step_number*(brightness_step_number-brightness_step)); + } + if ( id(master_mute_switch).state ) { + it[3] = muted_color; + it[9] = muted_color; + } + if (brightness_decreasing) { + brightness_step++; + } else { + brightness_step--; + } + if (brightness_step == 0 || brightness_step == brightness_step_number) { + brightness_decreasing = !brightness_decreasing; + } + - addressable_lambda: + name: "Timer Tick" + update_interval: 100ms + lambda: |- + auto light_color = id(led_ring).current_values; + Color color(light_color.get_red() * 255, light_color.get_green() * 255, + light_color.get_blue() * 255); + Color muted_color(255, 0, 0); + auto timer_ratio = 12.0f * id(first_active_timer).seconds_left / max(id(first_active_timer).total_seconds , static_cast(1)); + uint8_t last_led_on = static_cast(ceil(timer_ratio)) - 1; + for (uint8_t i = 0; i < 12; i++) { + float brightness_dip = ( i == id(global_led_animation_index) % 12 && i != last_led_on ) ? 0.9f : 1.0f ; + if (i <= timer_ratio) { + it[i] = color * min(255.0f * brightness_dip * (timer_ratio - i) , 255.0f * brightness_dip) ; + } else { + it[i] = Color::BLACK; + } + } + if (id(master_mute_switch).state) { + it[2] = Color::BLACK; + it[3] = muted_color; + it[4] = Color::BLACK; + it[8] = Color::BLACK; + it[9] = muted_color; + it[10] = Color::BLACK; + } + id(global_led_animation_index) = (12 + id(global_led_animation_index) - 1) % 12; + - addressable_rainbow: + name: "Rainbow" + width: 12 + - addressable_lambda: + name: "Tick" + update_interval: 333ms + lambda: |- + static uint8_t index = 0; + Color color(255, 0, 0); + if (initial_run) { + index = 0; + } + for (uint8_t i = 0; i < 12; i++) { + if (i <= index ) { + it[i] = Color::BLACK; + } else { + it[i] = color; + } + } + index = (index + 1) % 12; + - addressable_lambda: + name: "Factory Reset Coming Up" + update_interval: 1s + lambda: |- + static uint8_t index = 0; + Color color(255, 0, 0); + if (initial_run) { + index = 0; + } + for (uint8_t i = 0; i < 12; i++) { + if (i <= index ) { + it[i] = color; + } else { + it[i] = Color::BLACK; + } + } + index = (index + 1) % 12; + - addressable_lambda: + name: "Jack Plugged" + update_interval: 40ms + lambda: |- + static uint8_t index = 0; + if (initial_run) { + index = 0; + } + auto light_color = id(led_ring).current_values; + Color color(light_color.get_red() * 255, light_color.get_green() * 255, + light_color.get_blue() * 255); + if (index <= 6) { + for (uint8_t i = 0; i < 12; i++) { + if (i == index) { + it[i] = color; + } else if (i == (12 - index) % 12) { + it[i] = color; + } else { + it[i] = Color::BLACK; + } + } + } + index = (index + 1); + - addressable_lambda: + name: "Jack Unplugged" + update_interval: 40ms + lambda: |- + static uint8_t index = 0; + if (initial_run) { + index = 0; + } + auto light_color = id(led_ring).current_values; + Color color(light_color.get_red() * 255, light_color.get_green() * 255, + light_color.get_blue() * 255); + if (index <= 6) { + for (uint8_t i = 0; i < 12; i++) { + if (i == 6 - index) { + it[i] = color; + } else if (i == (6 + index) % 12) { + it[i] = color; + } else { + it[i] = Color::BLACK; + } + } + } + index = (index + 1); + + # User facing LED ring. Remapping of the internal LEDs. + # Exposed to be used by the user. + - platform: partition + id: led_ring + name: LED Ring + entity_category: config + icon: "mdi:circle-outline" + default_transition_length: 0ms + restore_mode: RESTORE_DEFAULT_OFF + initial_state: + color_mode: rgb + brightness: 66% + red: 9.4% + green: 73.3% + blue: 94.9% + segments: + - id: leds_internal + from: 7 + to: 11 + - id: leds_internal + from: 0 + to: 6 + +power_supply: + - id: led_power + pin: GPIO45 + +sensor: + # The dial. Used to control volume and Hue of the LED ring. + - platform: rotary_encoder + id: dial + pin_a: GPIO16 + pin_b: GPIO18 + resolution: 2 + on_clockwise: + - lambda: id(dial_touched) = true; + - if: + condition: + binary_sensor.is_off: center_button + then: + - script.execute: + id: control_volume + increase_volume: true + else: + - if: + condition: + media_player.is_playing: + id: sendspin_group_media_player + then: + - script.execute: + id: control_group_volume + increase_volume: true + else: + - script.execute: + id: control_hue + increase_hue: true + on_anticlockwise: + - lambda: id(dial_touched) = true; + - if: + condition: + binary_sensor.is_off: center_button + then: + - script.execute: + id: control_volume + increase_volume: false + else: + - if: + condition: + media_player.is_playing: + id: sendspin_group_media_player + then: + - script.execute: + id: control_group_volume + increase_volume: false + else: + - script.execute: + id: control_hue + increase_hue: false + +event: + # Event entity exposed to the user to automate on complex center button presses. + # The simple press is not exposed as it is used to control the device itself. + - platform: template + id: button_press_event + name: "Button press" + icon: mdi:button-pointer + device_class: button + event_types: + - double_press + - triple_press + - long_press + - easter_egg_press + +script: + # Master script controlling the LEDs, based on different conditions : initialization in progress, wifi and api connected and voice assistant phase. + # For the sake of simplicity and re-usability, the script calls child scripts defined below. + # This script will be called every time one of these conditions is changing. + - id: control_leds + then: + - lambda: | + if (id(voice_kit_component).is_failed()) { + id(control_leds_voice_kit_startup_failed).execute(); + return; + } + id(check_if_timers_active).execute(); + if (id(is_timer_active)){ + id(fetch_first_active_timer).execute(); + } + if (id(improv_ble_in_progress)) { + id(control_leds_improv_ble_state).execute(); + } else if (id(init_in_progress)) { + id(control_leds_init_state).execute(); + } else if (!id(wifi_id).is_connected() || !id(api_id).is_connected()){ + id(control_leds_no_ha_connection_state).execute(); + } else if (id(center_button).state) { + id(control_leds_center_button_touched).execute(); + } else if (id(jack_plugged_recently)) { + id(control_leds_jack_plugged_recently).execute(); + } else if (id(jack_unplugged_recently)) { + id(control_leds_jack_unplugged_recently).execute(); + } else if (id(dial_touched)) { + id(control_leds_dial_touched).execute(); + } else if (id(timer_ringing).state) { + id(control_leds_timer_ringing).execute(); + } else if (id(voice_assistant_phase) == ${voice_assist_waiting_for_command_phase_id}) { + id(control_leds_voice_assistant_waiting_for_command_phase).execute(); + } else if (id(voice_assistant_phase) == ${voice_assist_listening_for_command_phase_id}) { + id(control_leds_voice_assistant_listening_for_command_phase).execute(); + } else if (id(voice_assistant_phase) == ${voice_assist_thinking_phase_id}) { + id(control_leds_voice_assistant_thinking_phase).execute(); + } else if (id(voice_assistant_phase) == ${voice_assist_replying_phase_id}) { + id(control_leds_voice_assistant_replying_phase).execute(); + } else if (id(voice_assistant_phase) == ${voice_assist_error_phase_id}) { + id(control_leds_voice_assistant_error_phase).execute(); + } else if (id(voice_assistant_phase) == ${voice_assist_not_ready_phase_id}) { + id(control_leds_voice_assistant_not_ready_phase).execute(); + } else if (id(is_timer_active)) { + id(control_leds_timer_ticking).execute(); + } else if (id(master_mute_switch).state) { + id(control_leds_muted_or_silent).execute(); + } else if (id(external_media_player).volume == 0.0f || id(external_media_player).is_muted()) { + id(control_leds_muted_or_silent).execute(); + } else if (id(voice_assistant_phase) == ${voice_assist_idle_phase_id}) { + id(control_leds_voice_assistant_idle_phase).execute(); + } + + # Script executed if voice_kit startup failed + # Static red "X" + - id: control_leds_voice_kit_startup_failed + then: + - light.turn_on: + brightness: 40% + red: 0% + green: 0% + blue: 0% + id: voice_assistant_leds + effect: "Voice kit startup failed" + + # Script executed during Improv BLE + # Warm White Twinkle + - id: control_leds_improv_ble_state + then: + - light.turn_on: + brightness: 66% + red: 100% + green: 89% + blue: 71% + id: voice_assistant_leds + effect: "Twinkle" + + # Script executed during initialization + # Blue Twinkle if Wifi is connected, Else solid warm white + - id: control_leds_init_state + then: + - if: + condition: + wifi.connected: + then: + - light.turn_on: + brightness: 66% + red: 9.4% + green: 73.3% + blue: 94.9% + id: voice_assistant_leds + effect: "Twinkle" + else: + - light.turn_on: + brightness: 66% + red: 100% + green: 89% + blue: 71% + id: voice_assistant_leds + effect: "none" + + # Script executed when the device has no connection to Home Assistant + # Red Twinkle (This will be visible during HA updates for example) + - id: control_leds_no_ha_connection_state + then: + - light.turn_on: + brightness: 66% + red: 1 + green: 0 + blue: 0 + id: voice_assistant_leds + effect: "Twinkle" + + # Script executed when the voice assistant is idle (waiting for a wake word) + # Nothing (Either LED ring off or LED ring on if the user decided to turn the user facing LED ring on) + - id: control_leds_voice_assistant_idle_phase + then: + - light.turn_off: voice_assistant_leds + - if: + condition: + light.is_on: led_ring + then: + light.turn_on: led_ring + + # Script executed when the voice assistant is waiting for a command (After the wake word) + # Slow clockwise spin of the LED ring. + - id: control_leds_voice_assistant_waiting_for_command_phase + then: + - light.turn_on: + brightness: !lambda return max( id(led_ring).current_values.get_brightness() , 0.2f ); + id: voice_assistant_leds + effect: "Waiting for Command" + + # Script executed when the voice assistant is listening to a command + # Fast clockwise spin of the LED ring. + - id: control_leds_voice_assistant_listening_for_command_phase + then: + - light.turn_on: + brightness: !lambda return max( id(led_ring).current_values.get_brightness() , 0.2f ); + id: voice_assistant_leds + effect: "Listening For Command" + + # Script executed when the voice assistant is thinking to a command + # The spin stops and the 2 LEDs that are currently on and blinking indicating the commend is being processed. + - id: control_leds_voice_assistant_thinking_phase + then: + - light.turn_on: + brightness: !lambda return max( id(led_ring).current_values.get_brightness() , 0.2f ); + id: voice_assistant_leds + effect: "Thinking" + + # Script executed when the voice assistant is thinking to a command + # Fast anticlockwise spin of the LED ring. + - id: control_leds_voice_assistant_replying_phase + then: + - light.turn_on: + brightness: !lambda return max( id(led_ring).current_values.get_brightness() , 0.2f ); + id: voice_assistant_leds + effect: "Replying" + + # Script executed when the voice assistant is in error + # Fast Red Pulse + - id: control_leds_voice_assistant_error_phase + then: + - light.turn_on: + brightness: !lambda return min ( max( id(led_ring).current_values.get_brightness() , 0.2f ) + 0.1f , 1.0f ); + red: 1 + green: 0 + blue: 0 + id: voice_assistant_leds + effect: "Error" + + # Script executed when the voice assistant is muted or silent + # The LED next to the 2 microphones turn red / one red LED next to the speaker grill + - id: control_leds_muted_or_silent + then: + - light.turn_on: + brightness: !lambda return max( id(led_ring).current_values.get_brightness() , 0.2f ); + id: voice_assistant_leds + effect: "Muted or Silent" + + # Script executed when the voice assistant is not ready + - id: control_leds_voice_assistant_not_ready_phase + then: + - light.turn_on: + brightness: 66% + red: 1 + green: 0 + blue: 0 + id: voice_assistant_leds + effect: "Twinkle" + + # Script executed when the dial is touched + # A number of LEDs turn on indicating a visual representation of the volume of the media player entity. + - id: control_leds_dial_touched + then: + - light.turn_on: + brightness: !lambda return max( id(led_ring).current_values.get_brightness() , 0.2f ); + id: voice_assistant_leds + effect: "Volume Display" + + # Script executed when the jack has just been unplugged + # A ripple effect + - id: control_leds_jack_unplugged_recently + then: + - light.turn_on: + brightness: !lambda return max( id(led_ring).current_values.get_brightness() , 0.2f ); + id: voice_assistant_leds + effect: "Jack Unplugged" + + # Script executed when the jack has just been plugged + # A ripple effect + - id: control_leds_jack_plugged_recently + then: + - light.turn_on: + brightness: !lambda return max( id(led_ring).current_values.get_brightness() , 0.2f ); + id: voice_assistant_leds + effect: "Jack Plugged" + + # Script executed when the center button is touched + # The complete LED ring turns on + - id: control_leds_center_button_touched + then: + - light.turn_on: + brightness: !lambda return min ( max( id(led_ring).current_values.get_brightness() , 0.2f ) + 0.1f , 1.0f ); + id: voice_assistant_leds + effect: "Center Button Touched" + + # Script executed when the timer is ringing, to control the LEDs + # The LED ring blinks. + - id: control_leds_timer_ringing + then: + - light.turn_on: + brightness: !lambda return min ( max( id(led_ring).current_values.get_brightness() , 0.2f ) + 0.1f , 1.0f ); + id: voice_assistant_leds + effect: "Timer Ring" + + # Script executed when the timer is ticking, to control the LEDs + # The LEDs shows the remaining time as a fraction of the full ring. + - id: control_leds_timer_ticking + then: + - light.turn_on: + brightness: !lambda return max( id(led_ring).current_values.get_brightness() , 0.2f ); + id: voice_assistant_leds + effect: "Timer tick" + + # Script executed when the volume is increased/decreased from the dial + - id: control_volume + mode: restart + parameters: + increase_volume: bool # True: Increase volume / False: Decrease volume. + then: + - delay: 16ms + - if: + condition: + lambda: return increase_volume; + then: + - media_player.volume_up: + id: external_media_player + else: + - media_player.volume_down: + id: external_media_player + - script.execute: control_leds + - delay: 1s + - lambda: id(dial_touched) = false; + - sensor.rotary_encoder.set_value: + id: dial + value: 0 + - script.execute: control_leds + + # Script executed when the volume is increased/decreased from the dial for the group media player + - id: control_group_volume + mode: restart + parameters: + increase_volume: bool # True: Increase volume / False: Decrease volume. + then: + - delay: 16ms + - if: + condition: + lambda: return increase_volume; + then: + - lambda: id(group_volume_changed) = true; + - media_player.volume_up: + id: sendspin_group_media_player + else: + - lambda: id(group_volume_changed) = true; + - media_player.volume_down: + id: sendspin_group_media_player + - script.execute: control_leds + - delay: 1s + - lambda: id(dial_touched) = false; + - lambda: id(group_volume_changed) = false; + - sensor.rotary_encoder.set_value: + id: dial + value: 0 + - script.execute: control_leds + + # Script executed when the hue is increased/decreased from the dial + - id: control_hue + mode: restart + parameters: + increase_hue: bool # True: Increase hue / False: Decrease hue. + then: + - delay: 16ms + - if: + condition: + lambda: return(abs(int(id(dial).state)) > 3 || id(color_changed)); + then: + - lambda: | + id(color_changed) = true; + auto light_color = id(voice_assistant_leds).current_values; + int hue = 0; + float saturation = 0; + float value = 0; + rgb_to_hsv( light_color.get_red(), + light_color.get_green(), + light_color.get_blue(), + hue, + saturation, + value); + if (increase_hue) { + hue = (hue + 10) % 360; + } else { + hue = (hue + 350) % 360; + } + if (saturation < 0.05) { + saturation = 1; + } + float red = 0; + float green = 0; + float blue = 0; + hsv_to_rgb( hue, + saturation, + value, + red, + green, + blue); + id(voice_assistant_leds).make_call().set_rgb(red, green, blue).perform(); + - wait_until: + binary_sensor.is_off: center_button + - lambda: | + id(dial_touched) = false; + // now we "save" the new LED color/state to led_ring, maintaining its brightness and state + auto led_ring_call = id(led_ring).make_call(); + auto va_leds_cv = id(voice_assistant_leds).current_values; + led_ring_call.from_light_color_values(va_leds_cv); + led_ring_call.set_brightness(id(led_ring).current_values.get_brightness()); + led_ring_call.set_state(id(led_ring).current_values.is_on()); + led_ring_call.perform(); + - sensor.rotary_encoder.set_value: + id: dial + value: 0 + - script.execute: control_leds + - delay: 500ms + - lambda: id(color_changed) = false; + + # Script executed when the timer is ringing, to playback sounds. + - id: ring_timer + then: + - script.execute: enable_repeat_one + - script.execute: + id: play_sound + priority: true + sound_file: "timer_finished_sound" + + # Script executed when the timer is ringing, to repeat the timer finished sound. + - id: enable_repeat_one + then: + # Turn on the repeat mode and pause for 500 ms between playlist items/repeats + - media_player.repeat_one: + id: external_media_player + announcement: true + - speaker_source.set_playlist_delay: + id: external_media_player + pipeline: announcement + delay: 500ms + + # Script execute when the timer is done ringing, to disable repeat mode. + - id: disable_repeat + then: + # Turn off the repeat mode and pause for 0 ms between playlist items/repeats + - media_player.repeat_off: + id: external_media_player + announcement: true + - speaker_source.set_playlist_delay: + id: external_media_player + pipeline: announcement + delay: 0ms + + # Script executed when we want to play sounds on the device. + - id: play_sound + parameters: + priority: bool + sound_file: string + then: + - if: + condition: + lambda: return priority; + then: + - media_player.stop: + id: external_media_player + announcement: true + - lambda: |- + if ( (id(external_media_player).state != media_player::MediaPlayerState::MEDIA_PLAYER_STATE_ANNOUNCING ) || priority) { + id(external_media_player) + ->make_call() + .set_media_url("audio-file://" + sound_file) + .set_announcement(true) + .perform(); + } + + # Script used to fetch the first active timer (Stored in global first_active_timer) + - id: fetch_first_active_timer + then: + - lambda: | + const auto &timers = id(va).get_timers(); + auto output_timer = *timers.begin(); + for (const auto &timer : timers) { + if (timer.is_active && timer.seconds_left <= output_timer.seconds_left) { + output_timer = timer; + } + } + id(first_active_timer) = output_timer; + + # Script used to check if a timer is active (Stored in global is_timer_active) + - id: check_if_timers_active + then: + - lambda: | + const auto &timers = id(va).get_timers(); + bool output = false; + for (const auto &timer : timers) { + if (timer.is_active) { + output = true; + } + } + id(is_timer_active) = output; + + # Script used activate the stop word if the TTS step is long. + # Why is this wrapped on a script? + # Becasue we want to stop the sequence if the TTS step is faster than that. + # This allows us to prevent having the deactivation of the stop word before its own activation. + - id: activate_stop_word_once + then: + - wait_until: + condition: + media_player.is_announcing: + id: external_media_player + timeout: 5s + - delay: 1s + # Enable stop wake word + - if: + condition: + switch.is_off: timer_ringing + then: + - micro_wake_word.enable_model: stop + - wait_until: + not: + media_player.is_announcing: + id: external_media_player + - if: + condition: + switch.is_off: timer_ringing + then: + - micro_wake_word.disable_model: stop + +i2s_audio: + - id: i2s_output + # i2s_output data pin is gpio10 + i2s_lrclk_pin: + number: GPIO7 + i2s_bclk_pin: + number: GPIO8 + + - id: i2s_input + # data line is GPIO15 + i2s_lrclk_pin: + number: GPIO14 + i2s_bclk_pin: + number: GPIO13 + +microphone: + - platform: i2s_audio + id: i2s_mics + i2s_din_pin: GPIO15 + adc_type: external + pdm: false + sample_rate: 16000 + bits_per_sample: 32bit + i2s_mode: secondary + i2s_audio_id: i2s_input + channel: stereo + +speaker: + # Hardware speaker output + - platform: i2s_audio + id: i2s_audio_speaker + sample_rate: 48000 + i2s_mode: secondary + i2s_dout_pin: GPIO10 + bits_per_sample: 32bit + i2s_audio_id: i2s_output + dac_type: external + channel: stereo + timeout: never + buffer_duration: 100ms + audio_dac: aic3204_dac + + # Virtual speakers to combine the announcement and media streams together into one output + - platform: mixer + id: mixing_speaker + output_speaker: i2s_audio_speaker + num_channels: 2 + task_stack_in_psram: true + source_speakers: + - id: announcement_mixing_input + timeout: never + - id: media_mixing_input + timeout: never + + # Virtual speakers to resample each pipelines' audio, if necessary, as the mixer speaker requires the same sample rate + - platform: resampler + id: announcement_resampling_speaker + output_speaker: announcement_mixing_input + sample_rate: 48000 + bits_per_sample: 16 + - platform: resampler + id: media_resampling_speaker + output_speaker: media_mixing_input + sample_rate: 48000 + bits_per_sample: 16 + +sendspin: + id: sendspin_hub + task_stack_in_psram: false + +http_request: + buffer_size_rx: 2048 # Reduces CPU load when streaming audio + +audio_file: + - id: center_button_press_sound + file: ${center_button_press_sound_file} + - id: center_button_double_press_sound + file: ${center_button_double_press_sound_file} + - id: center_button_triple_press_sound + file: ${center_button_triple_press_sound_file} + - id: center_button_long_press_sound + file: ${center_button_long_press_sound_file} + - id: factory_reset_initiated_sound + file: ${factory_reset_initiated_sound_file} + - id: factory_reset_cancelled_sound + file: ${factory_reset_cancelled_sound_file} + - id: factory_reset_confirmed_sound + file: ${factory_reset_confirmed_sound_file} + - id: jack_connected_sound + file: ${jack_connected_sound_file} + - id: jack_disconnected_sound + file: ${jack_disconnected_sound_file} + - id: mute_switch_on_sound + file: ${mute_switch_on_sound_file} + - id: mute_switch_off_sound + file: ${mute_switch_off_sound_file} + - id: timer_finished_sound + file: ${timer_finished_sound_file} + - id: wake_word_triggered_sound + file: ${wake_word_triggered_sound_file} + - id: easter_egg_tick_sound + file: ${easter_egg_tick_sound_file} + - id: easter_egg_tada_sound + file: ${easter_egg_tada_sound_file} + - id: error_cloud_expired + file: ${error_cloud_expired_sound_file} + +media_source: + - platform: audio_file + id: audio_file_announcement_source + - platform: http_request + id: http_announcement_source + buffer_size: 250000 + - platform: http_request + id: http_media_source + buffer_size: 500000 + - platform: sendspin + id: sendspin_media_source + fixed_delay: 480 microseconds # The AIC3204 DAC used, as configured, on the VPE delays audio by 480 microseconds + +media_player: + - platform: sendspin + id: sendspin_group_media_player + - platform: speaker_source + id: external_media_player + name: Media Player + announcement_pipeline: + format: FLAC # FLAC is the least processor intensive codec + num_channels: 1 # Stereo audio is unnecessary for announcements + sample_rate: 48000 + speaker: announcement_resampling_speaker + sources: + - audio_file_announcement_source + - http_announcement_source + media_pipeline: + format: FLAC # FLAC is the least processor intensive codec + num_channels: 2 + sample_rate: 48000 + speaker: media_resampling_speaker + sources: + - http_media_source + - sendspin_media_source + volume_increment: 0.05 + volume_min: 0.4 + volume_max: 0.85 + on_mute: + - script.execute: control_leds + on_unmute: + - script.execute: control_leds + on_volume: + - script.execute: control_leds + on_announcement: + - mixer_speaker.apply_ducking: + id: media_mixing_input + decibel_reduction: 20 + duration: 0.0s + on_state: + if: + condition: + and: + - switch.is_off: timer_ringing + - not: + voice_assistant.is_running: + - not: + media_player.is_announcing: external_media_player + then: + - mixer_speaker.apply_ducking: + id: media_mixing_input + decibel_reduction: 0 + duration: 1.0s + +voice_kit: + id: voice_kit_component + i2c_id: internal_i2c + reset_pin: GPIO4 + firmware: + url: https://github.com/esphome/voice-kit-xmos-firmware/releases/download/v1.3.1/ffva_v1.3.1_upgrade.bin + version: "1.3.1" + md5: 964635c5bf125529dab14a2472a15401 + +# Sendspin related components are pinned to specific commits for reproducible builds +external_components: + - source: + type: git + url: https://github.com/esphome/home-assistant-voice-pe + ref: dev + components: + - voice_kit + refresh: 0s + - source: + # https://github.com/esphome/esphome/pull/14933 + type: git + url: https://github.com/kahrendt/esphome + ref: 7a6cf5c8472b7e2fa18ee0fc314f66a80d249e32 + components: [const, media_source, sendspin] + - source: + # https://github.com/esphome/esphome/pull/12429 + type: git + url: https://github.com/esphome/esphome + ref: ff8ce89556748509d7ee8724e12d9d43d3c8c1e8 + refresh: 0s + components: [http_request] + +audio_dac: + - platform: aic3204 + id: aic3204_dac + i2c_id: internal_i2c + +micro_wake_word: + id: mww + microphone: + microphone: i2s_mics + channels: 1 + gain_factor: 4 + stop_after_detection: false + models: + - model: https://github.com/kahrendt/microWakeWord/releases/download/okay_nabu_20241226.3/okay_nabu.json + id: okay_nabu + - model: hey_jarvis + id: hey_jarvis + - model: hey_mycroft + id: hey_mycroft + - model: https://github.com/kahrendt/microWakeWord/releases/download/stop/stop.json + id: stop + internal: true + vad: + on_wake_word_detected: + # If the wake word is detected when the device is muted (Possible with the software mute switch): Do nothing + - if: + condition: + switch.is_off: master_mute_switch + then: + # If a timer is ringing: Stop it, do not start the voice assistant (We can stop timer from voice!) + - if: + condition: + switch.is_on: timer_ringing + then: + - switch.turn_off: timer_ringing + # Stop voice assistant if running + else: + - if: + condition: + voice_assistant.is_running: + then: + voice_assistant.stop: + # Stop any other media player announcement + else: + - if: + condition: + media_player.is_announcing: + id: external_media_player + then: + - media_player.stop: + announcement: true + id: external_media_player + # Start the voice assistant and play the wake sound, if enabled + else: + - if: + condition: + switch.is_on: wake_sound + then: + - script.execute: + id: play_sound + priority: true + sound_file: "wake_word_triggered_sound" + - delay: 300ms + - voice_assistant.start: + wake_word: !lambda return wake_word; + +select: + - platform: template + name: "Wake word sensitivity" + id: wake_word_sensitivity + optimistic: true + initial_option: Slightly sensitive + restore_value: true + entity_category: config + options: + - Slightly sensitive + - Moderately sensitive + - Very sensitive + on_value: + # Sets specific wake word probabilities computed for each particular model + # Note probability cutoffs are set as a quantized uint8 value, each comment has the corresponding floating point cutoff + # False Accepts per Hour values are tested against all units and channels from the Dinner Party Corpus. + # These cutoffs apply only to the specific models included in the firmware: okay_nabu@20241226.3, hey_jarvis@v2, hey_mycroft@v2 + lambda: |- + if (x == "Slightly sensitive") { + id(okay_nabu).set_probability_cutoff(217); // 0.85 -> 0.000 FAPH on DipCo (Manifest's default) + id(hey_jarvis).set_probability_cutoff(247); // 0.97 -> 0.563 FAPH on DipCo (Manifest's default) + id(hey_mycroft).set_probability_cutoff(253); // 0.99 -> 0.567 FAPH on DipCo + } else if (x == "Moderately sensitive") { + id(okay_nabu).set_probability_cutoff(176); // 0.69 -> 0.376 FAPH on DipCo + id(hey_jarvis).set_probability_cutoff(235); // 0.92 -> 0.939 FAPH on DipCo + id(hey_mycroft).set_probability_cutoff(242); // 0.95 -> 1.502 FAPH on DipCo (Manifest's default) + } else if (x == "Very sensitive") { + id(okay_nabu).set_probability_cutoff(143); // 0.56 -> 0.751 FAPH on DipCo + id(hey_jarvis).set_probability_cutoff(212); // 0.83 -> 1.502 FAPH on DipCo + id(hey_mycroft).set_probability_cutoff(237); // 0.93 -> 1.878 FAPH on DipCo + } + +voice_assistant: + id: va + microphone: + microphone: i2s_mics + channels: 0 + media_player: external_media_player + micro_wake_word: mww + use_wake_word: false + noise_suppression_level: 0 + auto_gain: 0 dbfs + volume_multiplier: 1 + on_client_connected: + - lambda: id(init_in_progress) = false; + - micro_wake_word.start: + - lambda: id(voice_assistant_phase) = ${voice_assist_idle_phase_id}; + - script.execute: control_leds + on_client_disconnected: + - voice_assistant.stop: + - lambda: id(voice_assistant_phase) = ${voice_assist_not_ready_phase_id}; + - script.execute: control_leds + on_error: + # Only set the error phase if the error code is different than duplicate_wake_up_detected or stt-no-text-recognized + # These two are ignored for a better user experience + - if: + condition: + and: + - lambda: return !id(init_in_progress); + - lambda: return code != "duplicate_wake_up_detected"; + - lambda: return code != "stt-no-text-recognized"; + then: + - lambda: id(voice_assistant_phase) = ${voice_assist_error_phase_id}; + - script.execute: control_leds + # If the error code is cloud-auth-failed, serve a local audio file guiding the user. + - if: + condition: + - lambda: return code == "cloud-auth-failed"; + then: + - script.execute: + id: play_sound + priority: true + sound_file: "error_cloud_expired" + # When the voice assistant starts: Play a wake up sound, duck audio. + on_start: + - mixer_speaker.apply_ducking: + id: media_mixing_input + decibel_reduction: 20 # Number of dB quieter; higher implies more quiet, 0 implies full volume + duration: 0.0s # The duration of the transition (default is no transition) + on_listening: + - lambda: id(voice_assistant_phase) = ${voice_assist_waiting_for_command_phase_id}; + - script.execute: control_leds + on_stt_vad_start: + - lambda: id(voice_assistant_phase) = ${voice_assist_listening_for_command_phase_id}; + - script.execute: control_leds + on_stt_vad_end: + - lambda: id(voice_assistant_phase) = ${voice_assist_thinking_phase_id}; + - script.execute: control_leds + on_intent_progress: + - if: + condition: + # A nonempty x variable means a streaming TTS url was sent to the media player + lambda: 'return !x.empty();' + then: + - lambda: id(voice_assistant_phase) = ${voice_assist_replying_phase_id}; + - script.execute: control_leds + # Start a script that would potentially enable the stop word if the response is longer than a second + - script.execute: activate_stop_word_once + on_tts_start: + - if: + condition: + # The intent_progress trigger didn't start the TTS Reponse + lambda: 'return id(voice_assistant_phase) != ${voice_assist_replying_phase_id};' + then: + - lambda: id(voice_assistant_phase) = ${voice_assist_replying_phase_id}; + - script.execute: control_leds + # Start a script that would potentially enable the stop word if the response is longer than a second + - script.execute: activate_stop_word_once + # When the voice assistant ends ... + on_end: + - wait_until: + not: + voice_assistant.is_running: + # Stop ducking audio. + - mixer_speaker.apply_ducking: + id: media_mixing_input + decibel_reduction: 0 + duration: 1.0s + # If the end happened because of an error, let the error phase on for a second + - if: + condition: + lambda: return id(voice_assistant_phase) == ${voice_assist_error_phase_id}; + then: + - delay: 1s + # Reset the voice assistant phase id and reset the LED animations. + - lambda: id(voice_assistant_phase) = ${voice_assist_idle_phase_id}; + - script.execute: control_leds + on_timer_finished: + - switch.turn_on: timer_ringing + on_timer_started: + - script.execute: control_leds + on_timer_cancelled: + - script.execute: control_leds + on_timer_updated: + - script.execute: control_leds + on_timer_tick: + - script.execute: control_leds + +button: + - platform: factory_reset + id: factory_reset_button + name: "Factory Reset" + entity_category: diagnostic + internal: true + - platform: restart + id: restart_button + name: "Restart" + entity_category: config + disabled_by_default: true + icon: "mdi:restart" + +debug: + update_interval: 5s diff --git a/internal/live/errors.go b/internal/live/errors.go new file mode 100644 index 0000000..ce53726 --- /dev/null +++ b/internal/live/errors.go @@ -0,0 +1,9 @@ +package live + +import "errors" + +var ( + errNotFound = errors.New("preset not found") + errBadRequest = errors.New("invalid request") + errNoActivePreset = errors.New("no active preset") +) diff --git a/internal/live/http.go b/internal/live/http.go new file mode 100644 index 0000000..eda91a1 --- /dev/null +++ b/internal/live/http.go @@ -0,0 +1,293 @@ +package live + +import ( + "encoding/json" + "errors" + "net/http" + "time" + + "github.com/Sendspin/sendspin-go/pkg/sendspin" + + "rpi-sendspin/internal/pipewire" +) + +type HTTPServer struct { + mgr *Manager + store *Store + disc *Discovery + roster *Roster + srv *sendspin.Server +} + +func NewHTTPServer(mgr *Manager, store *Store, disc *Discovery, roster *Roster, srv *sendspin.Server) *HTTPServer { + return &HTTPServer{mgr: mgr, store: store, disc: disc, roster: roster, srv: srv} +} + +func (h *HTTPServer) Handler() http.Handler { + mux := http.NewServeMux() + + mux.HandleFunc("GET /api/presets", h.listPresets) + mux.HandleFunc("POST /api/presets", h.createPreset) + mux.HandleFunc("GET /api/presets/{id}", h.getPreset) + mux.HandleFunc("PUT /api/presets/{id}", h.putPreset) + mux.HandleFunc("DELETE /api/presets/{id}", h.deletePreset) + + mux.HandleFunc("GET /api/clients", h.listClients) + mux.HandleFunc("GET /api/sources", h.listSources) + mux.HandleFunc("GET /api/state", h.getState) + + mux.HandleFunc("POST /api/active_preset", h.setActivePreset) + mux.HandleFunc("POST /api/playback", h.setPlayback) + mux.HandleFunc("POST /api/volume", h.setVolume) + + mux.Handle("/", staticHandler()) + + return mux +} + +type setActivePresetReq struct { + ID string `json:"id"` +} + +func (h *HTTPServer) setActivePreset(w http.ResponseWriter, r *http.Request) { + var req setActivePresetReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httpError(w, http.StatusBadRequest, "invalid JSON") + return + } + if err := h.mgr.SetActivePreset(req.ID); err != nil { + httpError(w, MapManagerError(err), err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) +} + +type setPlaybackReq struct { + Command string `json:"command"` +} + +func (h *HTTPServer) setPlayback(w http.ResponseWriter, r *http.Request) { + var req setPlaybackReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httpError(w, http.StatusBadRequest, "invalid JSON") + return + } + if err := h.mgr.SetPlayback(req.Command); err != nil { + httpError(w, MapManagerError(err), err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) +} + +type setVolumeReq struct { + Volume int `json:"volume"` +} + +func (h *HTTPServer) setVolume(w http.ResponseWriter, r *http.Request) { + var req setVolumeReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httpError(w, http.StatusBadRequest, "invalid JSON") + return + } + if err := h.mgr.SetVolume(req.Volume); err != nil { + httpError(w, MapManagerError(err), err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *HTTPServer) listPresets(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, h.store.List()) +} + +func (h *HTTPServer) createPreset(w http.ResponseWriter, r *http.Request) { + var p Preset + if err := json.NewDecoder(r.Body).Decode(&p); err != nil { + httpError(w, http.StatusBadRequest, "invalid JSON") + return + } + if err := h.store.Create(p); err != nil { + httpError(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, http.StatusCreated, p) +} + +func (h *HTTPServer) getPreset(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + p, ok := h.store.Get(id) + if !ok { + httpError(w, http.StatusNotFound, "not found") + return + } + writeJSON(w, http.StatusOK, p) +} + +func (h *HTTPServer) putPreset(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + var p Preset + if err := json.NewDecoder(r.Body).Decode(&p); err != nil { + httpError(w, http.StatusBadRequest, "invalid JSON") + return + } + if p.ID == "" { + p.ID = id + } + if p.ID != id { + httpError(w, http.StatusBadRequest, "id mismatch") + return + } + if err := h.store.Put(p); err != nil { + httpError(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, http.StatusOK, p) +} + +func (h *HTTPServer) deletePreset(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + // If the deleted preset is currently active, transition to off first. + active, _, _, _ := h.mgr.State() + if active == id { + _ = h.mgr.SetActivePreset("") + } + ok, err := h.store.Delete(id) + if err != nil { + httpError(w, http.StatusInternalServerError, err.Error()) + return + } + if !ok { + httpError(w, http.StatusNotFound, "not found") + return + } + w.WriteHeader(http.StatusNoContent) +} + +type clientView struct { + ClientID string `json:"client_id,omitempty"` + Instance string `json:"instance,omitempty"` + Name string `json:"name,omitempty"` + Connected bool `json:"connected"` + LastSeen time.Time `json:"last_seen,omitempty"` + // Source indicates how this entry was discovered: + // "connected" → currently in the active group (after ClientFilter accepted hello) + // "roster" → handshaked at least once (we know its client_id; not currently in group) + // "mdns" → only seen via mDNS; never handshaked, no client_id known + Source string `json:"source"` +} + +func (h *HTTPServer) listClients(w http.ResponseWriter, _ *http.Request) { + // Build a unified view of every speaker we know about, keyed by + // client_id wherever possible so the UI can present a real, stable + // identifier rather than the mDNS instance name (which differs from + // client_id on most ESPHome / firmware-derived setups). + out := make([]clientView, 0) + byID := map[string]int{} + + // 1) Currently-connected clients: authoritative; takes precedence. + for _, c := range h.srv.Clients() { + byID[c.ID] = len(out) + out = append(out, clientView{ + ClientID: c.ID, + Name: c.Name, + Connected: true, + LastSeen: time.Now(), + Source: "connected", + }) + } + + // 2) Roster: every client_id we've seen via hello, even if rejected. + for _, e := range h.roster.List() { + if _, ok := byID[e.ClientID]; ok { + continue + } + byID[e.ClientID] = len(out) + out = append(out, clientView{ + ClientID: e.ClientID, + Name: e.Name, + Connected: false, + LastSeen: e.LastSeen, + Source: "roster", + }) + } + + // 3) mDNS-only entries: never handshaked, so we don't know + // client_id. Useful for "the speaker is on the network but + // something is wrong" diagnostics; not directly usable for + // preset membership. + for _, d := range h.disc.List() { + out = append(out, clientView{ + Instance: d.Instance, + Name: d.Instance, + LastSeen: d.LastSeen, + Source: "mdns", + }) + } + + writeJSON(w, http.StatusOK, out) +} + +type sourceView struct { + Name string `json:"name"` + Description string `json:"description"` + IsMonitor bool `json:"is_monitor"` +} + +func (h *HTTPServer) listSources(w http.ResponseWriter, _ *http.Request) { + targets, err := pipewire.ListCaptureTargets() + if err != nil { + httpError(w, http.StatusInternalServerError, err.Error()) + return + } + out := make([]sourceView, 0, len(targets)) + for _, t := range targets { + out = append(out, sourceView{ + Name: t.Name, + Description: t.Description, + IsMonitor: t.IsMonitor, + }) + } + writeJSON(w, http.StatusOK, out) +} + +type stateView struct { + ActivePreset string `json:"active_preset"` + Playback Playback `json:"playback"` + Volume int `json:"volume"` + Members []MemberState `json:"members"` +} + +func (h *HTTPServer) getState(w http.ResponseWriter, _ *http.Request) { + active, pb, vol, members := h.mgr.State() + writeJSON(w, http.StatusOK, stateView{ + ActivePreset: active, + Playback: pb, + Volume: vol, + Members: members, + }) +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} + +func httpError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} + +// MapManagerError converts manager sentinel errors to HTTP status codes. +// Currently unused by the HTTP layer (handlers use sentinel directly), +// but exported for potential use by the MQTT bridge command path. +func MapManagerError(err error) int { + switch { + case errors.Is(err, errNotFound): + return http.StatusNotFound + case errors.Is(err, errBadRequest), errors.Is(err, errNoActivePreset): + return http.StatusBadRequest + default: + return http.StatusInternalServerError + } +} + diff --git a/internal/live/manager.go b/internal/live/manager.go new file mode 100644 index 0000000..65620ba --- /dev/null +++ b/internal/live/manager.go @@ -0,0 +1,456 @@ +package live + +import ( + "log" + "sync" + + "github.com/Sendspin/sendspin-go/pkg/sendspin" +) + +type Playback string + +const ( + Off Playback = "off" + Paused Playback = "paused" + Playing Playback = "playing" +) + +// StateChange is published to subscribers (MQTT bridge) whenever the +// manager's externally-visible state changes. +type StateChange struct { + ActivePreset string + Playback Playback + Volume int + Presets []Preset +} + +// MemberState is per-speaker detail used by /api/state. +type MemberState struct { + ClientID string `json:"client_id"` + Connected bool `json:"connected"` + Muted bool `json:"muted"` + Volume int `json:"volume"` +} + +// Manager is the single source of truth for the live-audio server. All +// state transitions are serialized on its internal mutex. +type Manager struct { + store *Store + server *sendspin.Server + src *Source + + mu sync.Mutex + active string // preset id, "" when off + playback Playback + sourceOverride string // when non-empty, wins over preset.Source + + subscribers []chan StateChange + subscribersMu sync.Mutex +} + +func NewManager(store *Store, server *sendspin.Server, src *Source) *Manager { + m := &Manager{ + store: store, + server: server, + src: src, + playback: Off, + } + // React to preset edits: if the active preset's content changed (or + // it was deleted), reapply current state. + store.OnWrite(m.reapply) + return m +} + +// SetSourceOverride pins the captured PipeWire node, ignoring whatever +// `source` field the active preset carries. Passing "" reverts to +// preset-driven behavior. Safe to call before or after activation; the +// new value takes effect on the next preset transition (or immediately +// if a preset is already active). +func (m *Manager) SetSourceOverride(node string) { + m.mu.Lock() + m.sourceOverride = node + active := m.active + m.mu.Unlock() + + if active == "" { + return + } + // Reapply so the running pw-cat picks up the new target. + m.reapply() +} + +// resolveSource returns the PipeWire node to capture for the given +// preset, honoring sourceOverride when set. Caller must hold m.mu. +func (m *Manager) resolveSource(p Preset) string { + if m.sourceOverride != "" { + return m.sourceOverride + } + return p.Source +} + +// ClientAllowed is intended to be plugged into ServerConfig.ClientFilter. +// It admits a client only if it belongs to the currently active preset. +func (m *Manager) ClientAllowed(clientID string) bool { + m.mu.Lock() + defer m.mu.Unlock() + if m.active == "" { + return false + } + p, ok := m.store.Get(m.active) + if !ok { + return false + } + for _, id := range p.Members { + if id == clientID { + return true + } + } + return false +} + +// Subscribe returns a channel that receives a StateChange whenever the +// externally-visible state changes. The channel is buffered; slow +// subscribers drop events. +func (m *Manager) Subscribe() <-chan StateChange { + ch := make(chan StateChange, 8) + m.subscribersMu.Lock() + m.subscribers = append(m.subscribers, ch) + m.subscribersMu.Unlock() + return ch +} + +func (m *Manager) publish() { + m.subscribersMu.Lock() + subs := append([]chan StateChange(nil), m.subscribers...) + m.subscribersMu.Unlock() + + state := StateChange{ + ActivePreset: m.active, + Playback: m.playback, + Volume: m.groupVolumeLocked(), + Presets: m.store.List(), + } + for _, ch := range subs { + select { + case ch <- state: + default: + } + } +} + +// SetActivePreset transitions to the given preset id ("" → off). Members +// of the new preset are claimed; members no longer needed are released. +// Carries over playback state where possible. +func (m *Manager) SetActivePreset(id string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if id == m.active { + return nil + } + + var nextMembers map[string]struct{} + var nextSource string + if id != "" { + p, ok := m.store.Get(id) + if !ok { + return errNotFound + } + nextMembers = make(map[string]struct{}, len(p.Members)) + for _, id := range p.Members { + nextMembers[id] = struct{}{} + } + nextSource = m.resolveSource(p) + } + + // Drop currently-connected clients that don't belong to the new preset. + for _, c := range m.server.Clients() { + if _, ok := nextMembers[c.ID]; !ok { + m.server.DisconnectClient(c.ID) + } + } + + prev := m.active + m.active = id + + if id == "" { + // off + if m.playback == Playing { + m.server.NotifyStreamEndAll() + } + m.playback = Off + m.src.SetActive(false) + m.src.SetTarget("") + log.Printf("live: %s → off", prev) + m.publish() + return nil + } + + // On entry from off, default to paused; otherwise carry over. + if prev == "" { + m.playback = Paused + } + + m.src.SetTarget(nextSource) + if m.playback == Playing { + m.src.SetActive(true) + m.server.NotifyStreamStartAll() + } else { + m.src.SetActive(false) + } + + log.Printf("live: active preset %q (%s)", id, m.playback) + m.publish() + return nil +} + +// SetPlayback applies a play/pause/stop command. Stop sets the active +// preset to "". +func (m *Manager) SetPlayback(cmd string) error { + switch cmd { + case "stop": + return m.SetActivePreset("") + case "play": + return m.setPlayback(Playing) + case "pause": + return m.setPlayback(Paused) + default: + return errBadRequest + } +} + +func (m *Manager) setPlayback(next Playback) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.active == "" { + return errNoActivePreset + } + if m.playback == next { + return nil + } + prev := m.playback + m.playback = next + + switch next { + case Playing: + m.src.SetActive(true) + // If transitioning from paused, clients are already connected + // but received stream/end. Resend stream/start. + if prev == Paused { + m.server.NotifyStreamStartAll() + } + case Paused: + m.src.SetActive(false) + if prev == Playing { + m.server.NotifyStreamEndAll() + } + } + + log.Printf("live: %s → %s", prev, next) + m.publish() + return nil +} + +// SetVolume applies the spec's group-volume algorithm to the currently +// connected player members. No-op when off. +func (m *Manager) SetVolume(target int) error { + if target < 0 { + target = 0 + } + if target > 100 { + target = 100 + } + + m.mu.Lock() + defer m.mu.Unlock() + if m.active == "" { + return nil + } + + clients := m.server.Clients() + players := make([]sendspin.ClientInfo, 0, len(clients)) + for _, c := range clients { + players = append(players, c) + } + if len(players) == 0 { + return nil + } + + // Per spec ("Setting group volume", §Controller messages): apply + // the *full* delta to every player, clamp out-of-range values, then + // redistribute the lost delta across the still-eligible players. + // Iterate until nothing clamps or every player is at a boundary. + proposed := make(map[string]int, len(players)) + for _, c := range players { + proposed[c.ID] = c.Volume + } + + sum := 0 + for _, c := range players { + sum += proposed[c.ID] + } + delta := target - sum/len(players) + for _, c := range players { + proposed[c.ID] += delta + } + + clamped := make(map[string]bool, len(players)) + for range 10 { + lost := 0 + any := false + for _, c := range players { + v := proposed[c.ID] + if v < 0 { + lost += v // negative + proposed[c.ID] = 0 + if !clamped[c.ID] { + any = true + clamped[c.ID] = true + } + } else if v > 100 { + lost += v - 100 // positive + proposed[c.ID] = 100 + if !clamped[c.ID] { + any = true + clamped[c.ID] = true + } + } + } + if !any || lost == 0 { + break + } + + eligible := make([]string, 0, len(players)) + for _, c := range players { + if !clamped[c.ID] { + eligible = append(eligible, c.ID) + } + } + if len(eligible) == 0 { + break + } + + share := lost / len(eligible) + rem := lost - share*len(eligible) + for _, id := range eligible { + d := share + if rem > 0 { + d++ + rem-- + } else if rem < 0 { + d-- + rem++ + } + proposed[id] += d + } + } + + for _, c := range m.serverClientsByID(players) { + v := proposed[c.ID()] + _ = c.Send("server/command", map[string]any{ + "player": map[string]any{ + "command": "volume", + "volume": v, + }, + }) + } + + m.publish() + return nil +} + +// reapply is invoked by the preset store after a write so the manager +// can re-evaluate which speakers should be connected and what source to +// capture (e.g. the active preset's member list was edited, or it was +// deleted). +func (m *Manager) reapply() { + m.mu.Lock() + active := m.active + m.mu.Unlock() + + if active == "" { + return + } + p, ok := m.store.Get(active) + if !ok { + _ = m.SetActivePreset("") + return + } + + m.mu.Lock() + defer m.mu.Unlock() + allowed := make(map[string]struct{}, len(p.Members)) + for _, id := range p.Members { + allowed[id] = struct{}{} + } + for _, c := range m.server.Clients() { + if _, ok := allowed[c.ID]; !ok { + m.server.DisconnectClient(c.ID) + } + } + m.src.SetTarget(m.resolveSource(p)) + m.publish() +} + +// State returns a read-only snapshot for the HTTP /api/state endpoint. +func (m *Manager) State() (string, Playback, int, []MemberState) { + m.mu.Lock() + defer m.mu.Unlock() + + members := make([]MemberState, 0) + allowed := map[string]struct{}{} + if p, ok := m.store.Get(m.active); ok { + for _, id := range p.Members { + allowed[id] = struct{}{} + members = append(members, MemberState{ClientID: id}) + } + } + for _, c := range m.server.Clients() { + if _, ok := allowed[c.ID]; !ok { + continue + } + for i, mem := range members { + if mem.ClientID == c.ID { + members[i].Connected = true + members[i].Muted = c.Muted + members[i].Volume = c.Volume + } + } + } + return m.active, m.playback, m.groupVolumeLocked(), members +} + +func (m *Manager) groupVolumeLocked() int { + if m.active == "" { + return 0 + } + clients := m.server.Clients() + if len(clients) == 0 { + return 0 + } + sum := 0 + for _, c := range clients { + sum += c.Volume + } + return sum / len(clients) +} + +// serverClientsByID returns the live *ServerClient instances matching +// the IDs in the snapshot. Used so SetVolume can call .Send() rather +// than going through the snapshot, which is value-typed. +func (m *Manager) serverClientsByID(snapshot []sendspin.ClientInfo) []*sendspin.ServerClient { + if m.server.Group() == nil { + return nil + } + want := make(map[string]struct{}, len(snapshot)) + for _, c := range snapshot { + want[c.ID] = struct{}{} + } + out := make([]*sendspin.ServerClient, 0, len(snapshot)) + for _, c := range m.server.Group().Clients() { + if _, ok := want[c.ID()]; ok { + out = append(out, c) + } + } + return out +} diff --git a/internal/live/mdns.go b/internal/live/mdns.go new file mode 100644 index 0000000..1f8c3ce --- /dev/null +++ b/internal/live/mdns.go @@ -0,0 +1,164 @@ +package live + +import ( + "context" + "io" + "log" + "sort" + "strings" + "sync" + "time" + + "github.com/hashicorp/mdns" +) + +// DiscoveredClient is a snapshot of one mDNS advertisement for a +// Sendspin client (`_sendspin._tcp`). It does not include client_id — +// that field is only known after WebSocket handshake. The HTTP layer +// merges this snapshot with sendspin.Server.Clients() to produce the +// full /api/clients view. +type DiscoveredClient struct { + Instance string `json:"instance"` + Host string `json:"host"` + Port int `json:"port"` + LastSeen time.Time `json:"last_seen"` +} + +// Discovery continuously browses for _sendspin._tcp services. The list +// of currently-known clients is exposed via List(). Entries persist for +// `ttl` after their most recent sighting, then are pruned. +type Discovery struct { + ttl time.Duration + + mu sync.RWMutex + clients map[string]DiscoveredClient + + ctx context.Context + cancel context.CancelFunc +} + +func NewDiscovery() *Discovery { + ctx, cancel := context.WithCancel(context.Background()) + return &Discovery{ + ttl: 2 * time.Minute, + clients: make(map[string]DiscoveredClient), + ctx: ctx, + cancel: cancel, + } +} + +func (d *Discovery) Start() { + go d.browseLoop() + go d.pruneLoop() +} + +func (d *Discovery) Stop() { + d.cancel() +} + +func (d *Discovery) List() []DiscoveredClient { + d.mu.RLock() + defer d.mu.RUnlock() + out := make([]DiscoveredClient, 0, len(d.clients)) + for _, c := range d.clients { + out = append(out, c) + } + sort.Slice(out, func(i, j int) bool { return out[i].Instance < out[j].Instance }) + return out +} + +// sendspinService matches entries advertised under `_sendspin._tcp` — +// other devices on the LAN (WLED, Meshtastic, AirPlay, …) hit the same +// multicast channel and hashicorp/mdns happily writes them to our +// entries chan regardless of what we queried for. Filter by service +// type before recording. +const sendspinService = "_sendspin._tcp" + +func (d *Discovery) record(entry *mdns.ServiceEntry) { + if !strings.Contains(entry.Name, "."+sendspinService) { + return + } + host := "" + if entry.AddrV4 != nil { + host = entry.AddrV4.String() + } else if entry.AddrV6 != nil { + host = entry.AddrV6.String() + } + if host == "" { + return + } + instance := entry.Name + if i := strings.Index(instance, "."); i > 0 { + instance = instance[:i] + } + + d.mu.Lock() + d.clients[instance] = DiscoveredClient{ + Instance: instance, + Host: host, + Port: entry.Port, + LastSeen: time.Now(), + } + d.mu.Unlock() +} + +var silentLogger = log.New(io.Discard, "", 0) + +func (d *Discovery) browseLoop() { + for { + select { + case <-d.ctx.Done(): + return + default: + } + + entries := make(chan *mdns.ServiceEntry, 32) + done := make(chan struct{}) + + go func() { + defer close(done) + for entry := range entries { + d.record(entry) + } + }() + + params := &mdns.QueryParam{ + Service: "_sendspin._tcp", + Domain: "local", + Timeout: 5 * time.Second, + Entries: entries, + Logger: silentLogger, + } + if err := mdns.Query(params); err != nil { + log.Printf("mdns query: %v", err) + } + close(entries) + <-done + + select { + case <-d.ctx.Done(): + return + case <-time.After(10 * time.Second): + } + } +} + +func (d *Discovery) pruneLoop() { + t := time.NewTicker(30 * time.Second) + defer t.Stop() + for { + select { + case <-d.ctx.Done(): + return + case <-t.C: + cutoff := time.Now().Add(-d.ttl) + d.mu.Lock() + for k, v := range d.clients { + if v.LastSeen.Before(cutoff) { + delete(d.clients, k) + } + } + d.mu.Unlock() + } + } +} diff --git a/internal/live/mqtt.go b/internal/live/mqtt.go new file mode 100644 index 0000000..93c2ded --- /dev/null +++ b/internal/live/mqtt.go @@ -0,0 +1,241 @@ +package live + +import ( + "encoding/json" + "fmt" + "log" + "strconv" + "strings" + "time" + + mqtt "github.com/eclipse/paho.mqtt.golang" +) + +// MQTTConfig holds the bridge configuration loaded from CLI flags / env. +type MQTTConfig struct { + URL string + Username string + Password string + BaseTopic string // e.g., "sendspin/live" + DiscoveryPrefix string // e.g., "homeassistant" + InstanceID string // used in HA discovery object_ids; defaults from BaseTopic +} + +type MQTTBridge struct { + cfg MQTTConfig + mgr *Manager + client mqtt.Client +} + +func NewMQTTBridge(cfg MQTTConfig, mgr *Manager) *MQTTBridge { + if cfg.BaseTopic == "" { + cfg.BaseTopic = "sendspin/live" + } + if cfg.DiscoveryPrefix == "" { + cfg.DiscoveryPrefix = "homeassistant" + } + if cfg.InstanceID == "" { + cfg.InstanceID = strings.ReplaceAll(cfg.BaseTopic, "/", "_") + } + return &MQTTBridge{cfg: cfg, mgr: mgr} +} + +func (b *MQTTBridge) Start() error { + opts := mqtt.NewClientOptions(). + AddBroker(b.cfg.URL). + SetClientID("sendspin-live-" + b.cfg.InstanceID). + SetUsername(b.cfg.Username). + SetPassword(b.cfg.Password). + SetAutoReconnect(true). + SetCleanSession(true). + SetKeepAlive(30 * time.Second). + SetWill(b.topic("availability"), "offline", 1, true). + SetOnConnectHandler(b.onConnect). + SetConnectionLostHandler(func(_ mqtt.Client, err error) { + log.Printf("mqtt connection lost: %v", err) + }) + + b.client = mqtt.NewClient(opts) + tok := b.client.Connect() + tok.Wait() + if err := tok.Error(); err != nil { + return fmt.Errorf("mqtt connect: %w", err) + } + + // React to manager state changes. + go b.publishLoop(b.mgr.Subscribe()) + return nil +} + +func (b *MQTTBridge) Stop() { + if b.client != nil && b.client.IsConnected() { + _ = b.publish("availability", "offline", true) + b.client.Disconnect(250) + } +} + +func (b *MQTTBridge) topic(suffix string) string { + return b.cfg.BaseTopic + "/" + suffix +} + +func (b *MQTTBridge) publish(suffix, payload string, retain bool) error { + t := b.client.Publish(b.topic(suffix), 1, retain, payload) + t.Wait() + return t.Error() +} + +func (b *MQTTBridge) onConnect(_ mqtt.Client) { + log.Printf("mqtt connected") + _ = b.publish("availability", "online", true) + b.publishDiscovery() + + // Snapshot the current state and publish. + active, pb, vol, _ := b.mgr.State() + b.publishState(StateChange{ + ActivePreset: active, + Playback: pb, + Volume: vol, + Presets: b.mgr.store.List(), + }) + + // Subscribe command topics. + b.client.Subscribe(b.topic("active_preset/set"), 1, b.handleActivePresetSet) + b.client.Subscribe(b.topic("playback/set"), 1, b.handlePlaybackSet) + b.client.Subscribe(b.topic("volume/set"), 1, b.handleVolumeSet) +} + +func (b *MQTTBridge) handleActivePresetSet(_ mqtt.Client, msg mqtt.Message) { + id := strings.TrimSpace(string(msg.Payload())) + if err := b.mgr.SetActivePreset(id); err != nil { + log.Printf("mqtt active_preset/set %q: %v", id, err) + } +} + +func (b *MQTTBridge) handlePlaybackSet(_ mqtt.Client, msg mqtt.Message) { + cmd := strings.TrimSpace(string(msg.Payload())) + if err := b.mgr.SetPlayback(cmd); err != nil { + log.Printf("mqtt playback/set %q: %v", cmd, err) + } +} + +func (b *MQTTBridge) handleVolumeSet(_ mqtt.Client, msg mqtt.Message) { + v, err := strconv.Atoi(strings.TrimSpace(string(msg.Payload()))) + if err != nil { + log.Printf("mqtt volume/set bad payload: %v", err) + return + } + if err := b.mgr.SetVolume(v); err != nil { + log.Printf("mqtt volume/set: %v", err) + } +} + +func (b *MQTTBridge) publishLoop(events <-chan StateChange) { + for ev := range events { + b.publishState(ev) + } +} + +func (b *MQTTBridge) publishState(ev StateChange) { + _ = b.publish("active_preset", ev.ActivePreset, true) + _ = b.publish("playback", string(ev.Playback), true) + _ = b.publish("volume", strconv.Itoa(ev.Volume), true) + + type p struct { + ID string `json:"id"` + Name string `json:"name"` + } + out := make([]p, 0, len(ev.Presets)) + for _, pr := range ev.Presets { + out = append(out, p{ID: pr.ID, Name: pr.Name}) + } + data, _ := json.Marshal(out) + _ = b.publish("presets", string(data), true) + + // HA discovery for the preset select depends on the list of preset + // IDs, so republish the select config whenever presets change. + b.publishSelectConfig(ev.Presets) +} + +// publishDiscovery emits HA MQTT discovery payloads for the entities +// this bridge exposes. Retained + idempotent so repeating on every +// connect is harmless. +func (b *MQTTBridge) publishDiscovery() { + b.publishSelectConfig(b.mgr.store.List()) + + // Playback select. + b.publishConfig("select", "playback", map[string]any{ + "name": "Sendspin Live Playback", + "unique_id": b.cfg.InstanceID + "_playback", + "command_topic": b.topic("playback/set"), + "state_topic": b.topic("playback"), + "options": []string{"playing", "paused", "off"}, + "availability_topic": b.topic("availability"), + "device": b.deviceBlock(), + }) + + // Volume number. + b.publishConfig("number", "volume", map[string]any{ + "name": "Sendspin Live Volume", + "unique_id": b.cfg.InstanceID + "_volume", + "command_topic": b.topic("volume/set"), + "state_topic": b.topic("volume"), + "min": 0, + "max": 100, + "step": 1, + "mode": "slider", + "availability_topic": b.topic("availability"), + "device": b.deviceBlock(), + }) + + // Availability binary_sensor. + b.publishConfig("binary_sensor", "availability", map[string]any{ + "name": "Sendspin Live Available", + "unique_id": b.cfg.InstanceID + "_availability", + "state_topic": b.topic("availability"), + "payload_on": "online", + "payload_off": "offline", + "device_class": "connectivity", + "device": b.deviceBlock(), + }) +} + +func (b *MQTTBridge) publishSelectConfig(presets []Preset) { + options := make([]string, 0, len(presets)+1) + options = append(options, "") + for _, p := range presets { + options = append(options, p.ID) + } + b.publishConfig("select", "active_preset", map[string]any{ + "name": "Sendspin Live Preset", + "unique_id": b.cfg.InstanceID + "_active_preset", + "command_topic": b.topic("active_preset/set"), + "state_topic": b.topic("active_preset"), + "options": options, + "availability_topic": b.topic("availability"), + "device": b.deviceBlock(), + }) +} + +func (b *MQTTBridge) publishConfig(component, objectID string, payload map[string]any) { + topic := fmt.Sprintf("%s/%s/%s/%s/config", + b.cfg.DiscoveryPrefix, component, b.cfg.InstanceID, objectID) + data, err := json.Marshal(payload) + if err != nil { + log.Printf("mqtt discovery marshal: %v", err) + return + } + t := b.client.Publish(topic, 1, true, data) + t.Wait() + if err := t.Error(); err != nil { + log.Printf("mqtt publish %s: %v", topic, err) + } +} + +func (b *MQTTBridge) deviceBlock() map[string]any { + return map[string]any{ + "identifiers": []string{b.cfg.InstanceID}, + "name": "Sendspin Live", + "manufacturer": "sendspin", + "model": "live-server", + } +} diff --git a/internal/live/preset.go b/internal/live/preset.go new file mode 100644 index 0000000..f0cd42d --- /dev/null +++ b/internal/live/preset.go @@ -0,0 +1,168 @@ +package live + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "sync" +) + +// Preset maps one PipeWire capture source to a set of Sendspin clients. +// Members are sendspin `client_id` values, not friendly names. +type Preset struct { + ID string `json:"id"` + Name string `json:"name"` + Source string `json:"source"` + Members []string `json:"members"` +} + +var idRe = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) + +func (p *Preset) validate() error { + if p.ID == "" { + return fmt.Errorf("id is required") + } + if !idRe.MatchString(p.ID) { + return fmt.Errorf("id must be lowercase kebab-case") + } + if p.Name == "" { + return fmt.Errorf("name is required") + } + if p.Source == "" { + return fmt.Errorf("source is required") + } + return nil +} + +// Store is an in-memory, file-backed preset collection. Writes are +// serialized; the file is rewritten atomically on every change. +type Store struct { + mu sync.RWMutex + path string + presets map[string]Preset + onWrite func() +} + +func NewStore(path string) (*Store, error) { + s := &Store{ + path: path, + presets: make(map[string]Preset), + } + if err := s.load(); err != nil { + return nil, err + } + return s, nil +} + +// OnWrite registers a callback that runs after every successful write. +// Used by the manager to refresh active state when the active preset +// changes underneath it. +func (s *Store) OnWrite(fn func()) { + s.mu.Lock() + s.onWrite = fn + s.mu.Unlock() +} + +func (s *Store) load() error { + data, err := os.ReadFile(s.path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + if len(data) == 0 { + return nil + } + var list []Preset + if err := json.Unmarshal(data, &list); err != nil { + return fmt.Errorf("parse %s: %w", s.path, err) + } + for _, p := range list { + s.presets[p.ID] = p + } + return nil +} + +func (s *Store) writeLocked() error { + if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { + return err + } + list := make([]Preset, 0, len(s.presets)) + for _, p := range s.presets { + list = append(list, p) + } + sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID }) + + data, err := json.MarshalIndent(list, "", " ") + if err != nil { + return err + } + + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + if err := os.Rename(tmp, s.path); err != nil { + return err + } + if s.onWrite != nil { + go s.onWrite() + } + return nil +} + +func (s *Store) List() []Preset { + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]Preset, 0, len(s.presets)) + for _, p := range s.presets { + out = append(out, p) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out +} + +func (s *Store) Get(id string) (Preset, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + p, ok := s.presets[id] + return p, ok +} + +func (s *Store) Put(p Preset) error { + if err := p.validate(); err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + s.presets[p.ID] = p + return s.writeLocked() +} + +// Create adds a preset, failing if the ID already exists. +func (s *Store) Create(p Preset) error { + if err := p.validate(); err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + if _, exists := s.presets[p.ID]; exists { + return fmt.Errorf("preset %q already exists", p.ID) + } + s.presets[p.ID] = p + return s.writeLocked() +} + +func (s *Store) Delete(id string) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.presets[id]; !ok { + return false, nil + } + delete(s.presets, id) + return true, s.writeLocked() +} diff --git a/internal/live/roster.go b/internal/live/roster.go new file mode 100644 index 0000000..80db44d --- /dev/null +++ b/internal/live/roster.go @@ -0,0 +1,58 @@ +package live + +import ( + "sort" + "sync" + "time" +) + +// RosterEntry is one observed Sendspin client. Populated from +// client/hello messages — independent of whether the connection was +// then accepted or rejected. This is what gives the UI the ability to +// show the *actual* `client_id` of every speaker on the network, even +// when ClientFilter rejected its current attempt. +type RosterEntry struct { + ClientID string `json:"client_id"` + Name string `json:"name"` + LastSeen time.Time `json:"last_seen"` +} + +// Roster caches every (client_id, name) pair the sendspin server has +// observed via client/hello. Records are kept indefinitely — speakers +// usually keep the same client_id across reboots, so a stale entry +// remains useful when the user is configuring presets offline. +type Roster struct { + mu sync.RWMutex + entries map[string]RosterEntry +} + +func NewRoster() *Roster { + return &Roster{entries: make(map[string]RosterEntry)} +} + +// Record is intended to be plugged into ServerConfig.OnClientHello. +// Updates the entry's LastSeen on every call so the UI can show +// "active now" vs. "last seen 5 minutes ago". +func (r *Roster) Record(clientID, name string) { + if clientID == "" { + return + } + r.mu.Lock() + defer r.mu.Unlock() + r.entries[clientID] = RosterEntry{ + ClientID: clientID, + Name: name, + LastSeen: time.Now(), + } +} + +func (r *Roster) List() []RosterEntry { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]RosterEntry, 0, len(r.entries)) + for _, e := range r.entries { + out = append(out, e) + } + sort.Slice(out, func(i, j int) bool { return out[i].ClientID < out[j].ClientID }) + return out +} diff --git a/internal/live/source.go b/internal/live/source.go new file mode 100644 index 0000000..3053c93 --- /dev/null +++ b/internal/live/source.go @@ -0,0 +1,141 @@ +package live + +import ( + "log" + "sync" + "time" + + "rpi-sendspin/internal/pipewire" +) + +// Source is the sendspin AudioSource used by the live server. It wraps +// a single pipewire.Source instance and exposes runtime control: +// +// - SetTarget swaps the captured PipeWire node and recreates the +// underlying pw-cat process. +// - SetActive(false) makes Read() return (0, nil) on every call so the +// sendspin audio engine skips its tick without surfacing an error. +// pw-cat is killed in this mode so we don't burn CPU capturing audio +// no one is listening to. +// +// The sample rate / channel count are fixed at construction; v1 of the +// live server does not retune between presets. +type Source struct { + sampleRate int + channels int + + mu sync.Mutex + target string + active bool + pw *pipewire.Source + retryAt time.Time // earliest time the next pw-cat start may be attempted + failures int // consecutive failures, drives exponential backoff + lastError string // last error message; logged once per change to avoid spam +} + +const ( + backoffMin = 500 * time.Millisecond + backoffMax = 10 * time.Second +) + +func NewSource(sampleRate, channels int) *Source { + return &Source{ + sampleRate: sampleRate, + channels: channels, + } +} + +// SetTarget sets the PipeWire node to capture from. If the target +// changes while the source is active, the underlying pw-cat process is +// torn down and recreated on the next Read. +func (s *Source) SetTarget(target string) { + s.mu.Lock() + defer s.mu.Unlock() + if s.target == target { + return + } + s.target = target + s.failures = 0 + s.retryAt = time.Time{} + s.lastError = "" + s.teardownLocked() +} + +// SetActive controls whether Read pulls from pw-cat. When set to false +// any running pw-cat is killed and subsequent Reads return (0, nil). +func (s *Source) SetActive(active bool) { + s.mu.Lock() + defer s.mu.Unlock() + if s.active == active { + return + } + s.active = active + if !active { + s.teardownLocked() + } +} + +func (s *Source) teardownLocked() { + if s.pw != nil { + _ = s.pw.Close() + s.pw = nil + } +} + +func (s *Source) Read(samples []int32) (int, error) { + s.mu.Lock() + if !s.active || s.target == "" { + s.mu.Unlock() + return 0, nil + } + // Backoff: when pw-cat keeps dying (e.g. invalid target), don't + // fork-bomb a new process on every 20 ms tick. Return (0, nil) so + // the sendspin audio engine skips the tick silently until retryAt. + if !s.retryAt.IsZero() && time.Now().Before(s.retryAt) { + s.mu.Unlock() + return 0, nil + } + if s.pw == nil { + s.pw = pipewire.NewSource(s.target, s.sampleRate, s.channels) + } + pw := s.pw + target := s.target + s.mu.Unlock() + + n, err := pw.Read(samples) + if err != nil { + s.mu.Lock() + s.teardownLocked() + s.failures++ + backoff := backoffMin << (s.failures - 1) + if backoff > backoffMax || backoff <= 0 { + backoff = backoffMax + } + s.retryAt = time.Now().Add(backoff) + msg := err.Error() + if msg != s.lastError { + log.Printf("pw-cat[%s] read error: %v (retry in %s)", target, err, backoff) + s.lastError = msg + } + s.mu.Unlock() + return 0, nil + } + if n > 0 { + s.mu.Lock() + s.failures = 0 + s.lastError = "" + s.mu.Unlock() + } + return n, nil +} + +func (s *Source) SampleRate() int { return s.sampleRate } +func (s *Source) Channels() int { return s.channels } +func (s *Source) Metadata() (string, string, string) { return "", "", "" } + +func (s *Source) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + s.teardownLocked() + return nil +} diff --git a/internal/live/web.go b/internal/live/web.go new file mode 100644 index 0000000..6f9d43c --- /dev/null +++ b/internal/live/web.go @@ -0,0 +1,19 @@ +package live + +import ( + "embed" + "io/fs" + "net/http" +) + +//go:embed web/index.html +var webFS embed.FS + +func staticHandler() http.Handler { + sub, err := fs.Sub(webFS, "web") + if err != nil { + // Embed misconfiguration is a build-time bug; fall back to 404. + return http.NotFoundHandler() + } + return http.FileServer(http.FS(sub)) +} diff --git a/internal/live/web/index.html b/internal/live/web/index.html new file mode 100644 index 0000000..7e13570 --- /dev/null +++ b/internal/live/web/index.html @@ -0,0 +1,593 @@ + + + + + +Sendspin Live + + + + +
+

Sendspin Live

+ off + +
+ +
+
+

+ Now playing +

+
+
+ Active preset + +
+
+ Playback +
+ + + +
+
+
+ Volume +
+
+ +
+
+ Members +
+
+
+
+ +
+

+ Discovered clients + +

+
+
+ +
+

+ Presets + +

+
+
+
+ + + + + + diff --git a/internal/pipewire/nodes.go b/internal/pipewire/nodes.go new file mode 100644 index 0000000..7a75d59 --- /dev/null +++ b/internal/pipewire/nodes.go @@ -0,0 +1,155 @@ +package pipewire + +import ( + "encoding/json" + "fmt" + "os/exec" + "strings" +) + +type Node struct { + ID int + Name string + Description string + MediaClass string +} + +func (n Node) String() string { + if n.Description != "" && n.Description != n.Name { + return fmt.Sprintf("%s [%s]", n.Description, n.Name) + } + return n.Name +} + +type pwObject struct { + ID int `json:"id"` + Type string `json:"type"` + Info struct { + Props struct { + MediaClass string `json:"media.class"` + NodeName string `json:"node.name"` + NodeDesc string `json:"node.description"` + } `json:"props"` + } `json:"info"` +} + +func listAllNodes() ([]Node, error) { + out, err := exec.Command("pw-dump").Output() + if err != nil { + return nil, fmt.Errorf("pw-dump: %w", err) + } + + var objects []pwObject + if err := json.Unmarshal(out, &objects); err != nil { + return nil, fmt.Errorf("parse pw-dump: %w", err) + } + + var nodes []Node + for _, o := range objects { + if o.Type != "PipeWire:Interface:Node" { + continue + } + if !strings.HasPrefix(o.Info.Props.MediaClass, "Audio/") || o.Info.Props.NodeName == "" { + continue + } + nodes = append(nodes, Node{ + ID: o.ID, + Name: o.Info.Props.NodeName, + Description: o.Info.Props.NodeDesc, + MediaClass: o.Info.Props.MediaClass, + }) + } + return nodes, nil +} + +func ListSinks() ([]Node, error) { + all, err := listAllNodes() + if err != nil { + return nil, err + } + var out []Node + for _, n := range all { + if n.MediaClass == "Audio/Sink" { + out = append(out, n) + } + } + return out, nil +} + +func ListSources() ([]Node, error) { + all, err := listAllNodes() + if err != nil { + return nil, err + } + var out []Node + for _, n := range all { + switch n.MediaClass { + case "Audio/Source", "Audio/Source.Virtual": + out = append(out, n) + } + } + return out, nil +} + +// CaptureTarget is a node that can be fed to `pw-cat --capture --target`. +// Real microphones / line-ins / virtual sources have IsMonitor=false. +// Audio sinks are surfaced with IsMonitor=true: pw-cat reads the monitor +// port when given a sink as --target, so the live server can stream +// whatever audio the host is currently playing. +type CaptureTarget struct { + Name string + Description string + IsMonitor bool +} + +// ListCaptureTargets returns every PipeWire node usable as audio input +// for the live server. Sinks are surfaced as monitor capture targets +// (pw-cat captures the monitor port when given a sink as --target); +// `.monitor` pseudo-sources exposed by the PulseAudio compatibility +// layer are also included since `pw-cat --target .monitor` is +// the canonical way to capture playback on many setups. +// +// Order: real microphones / line-ins first, then monitors (the things +// most users actually want for "stream what's playing"). +func ListCaptureTargets() ([]CaptureTarget, error) { + all, err := listAllNodes() + if err != nil { + return nil, err + } + + var realSources, monitorSources, sinkMonitors []CaptureTarget + monitorNames := map[string]bool{} + + for _, n := range all { + switch n.MediaClass { + case "Audio/Source", "Audio/Source.Virtual": + if strings.HasSuffix(n.Name, ".monitor") { + monitorSources = append(monitorSources, CaptureTarget{ + Name: n.Name, Description: n.Description, IsMonitor: true, + }) + monitorNames[n.Name] = true + } else { + realSources = append(realSources, CaptureTarget{ + Name: n.Name, Description: n.Description, + }) + } + case "Audio/Sink": + // Skip the sink itself when its corresponding ".monitor" + // source already covers the same capture path — pick one + // canonical name (the source) to avoid two indistinguishable + // dropdown entries. + if monitorNames[n.Name+".monitor"] { + continue + } + sinkMonitors = append(sinkMonitors, CaptureTarget{ + Name: n.Name, Description: n.Description, IsMonitor: true, + }) + } + } + + out := make([]CaptureTarget, 0, len(realSources)+len(monitorSources)+len(sinkMonitors)) + out = append(out, realSources...) + out = append(out, monitorSources...) + out = append(out, sinkMonitors...) + return out, nil +} diff --git a/internal/pipewire/output.go b/internal/pipewire/output.go new file mode 100644 index 0000000..689edcd --- /dev/null +++ b/internal/pipewire/output.go @@ -0,0 +1,165 @@ +package pipewire + +import ( + "encoding/binary" + "encoding/json" + "fmt" + "io" + "log" + "os/exec" + "strconv" + "strings" + "sync" +) + +// Output implements the sendspin output.Output interface via pw-cat --playback. +type Output struct { + target string + nodeName string + + mu sync.Mutex + cmd *exec.Cmd + stdin io.WriteCloser + sampleRate int + channels int + bitShift uint + buf []byte + + volume float64 + muted bool +} + +func NewOutput(target, nodeName string) *Output { + return &Output{ + target: target, + nodeName: nodeName, + volume: 1.0, + } +} + +func (o *Output) Open(sampleRate, channels, bitDepth int) error { + o.mu.Lock() + defer o.mu.Unlock() + + o.sampleRate = sampleRate + o.channels = channels + // Samples from sendspin are right-justified in int32; shift to fill + // the full 32-bit range so pw-cat receives proper amplitude. + if bitDepth > 0 && bitDepth < 32 { + o.bitShift = uint(32 - bitDepth) + } else { + o.bitShift = 0 + } + o.killUnlocked() + return o.startUnlocked() +} + +func (o *Output) startUnlocked() error { + nameJSON, _ := json.Marshal(o.nodeName) + props := fmt.Sprintf(`{"node.name":%s}`, nameJSON) + + args := []string{ + "--playback", + "--raw", + "--format", "s32", + "--rate", strconv.Itoa(o.sampleRate), + "--channels", strconv.Itoa(o.channels), + "--properties", props, + } + if o.target != "" { + args = append(args, "--target", o.target) + } + args = append(args, "-") + + cmd := exec.Command("pw-cat", args...) + + stderr, _ := cmd.StderrPipe() + stdin, err := cmd.StdinPipe() + if err != nil { + return fmt.Errorf("pw-cat stdin: %w", err) + } + if err := cmd.Start(); err != nil { + return fmt.Errorf("pw-cat start: %w", err) + } + + go func() { + b, _ := io.ReadAll(stderr) + if s := strings.TrimSpace(string(b)); s != "" { + log.Printf("pw-cat stderr: %s", s) + } + }() + + o.cmd = cmd + o.stdin = stdin + if o.buf == nil { + o.buf = make([]byte, 0, 4096) + } + return nil +} + +func (o *Output) killUnlocked() { + if o.stdin != nil { + o.stdin.Close() + o.stdin = nil + } + if o.cmd != nil && o.cmd.Process != nil { + o.cmd.Process.Kill() + o.cmd.Wait() + o.cmd = nil + } +} + +func (o *Output) Write(samples []int32) error { + o.mu.Lock() + defer o.mu.Unlock() + + if o.stdin == nil { + return fmt.Errorf("output not opened") + } + + need := len(samples) * 4 + if cap(o.buf) < need { + o.buf = make([]byte, need) + } + o.buf = o.buf[:need] + + for i, s := range samples { + if o.muted { + s = 0 + } else if o.volume != 1.0 { + s = int32(float64(s) * o.volume) + } + s <<= o.bitShift + binary.LittleEndian.PutUint32(o.buf[i*4:], uint32(s)) + } + + _, err := o.stdin.Write(o.buf) + if err != nil { + log.Printf("pw-cat pipe broken (%v), restarting", err) + o.killUnlocked() + if rerr := o.startUnlocked(); rerr != nil { + return fmt.Errorf("pw-cat restart: %w", rerr) + } + _, err = o.stdin.Write(o.buf) + } + return err +} + +func (o *Output) SetVolume(volume int) { + o.mu.Lock() + o.volume = float64(volume) / 100.0 + o.mu.Unlock() +} + +func (o *Output) SetMuted(muted bool) { + o.mu.Lock() + o.muted = muted + o.mu.Unlock() +} + +func (o *Output) Close() error { + o.mu.Lock() + defer o.mu.Unlock() + o.killUnlocked() + return nil +} diff --git a/internal/pipewire/source.go b/internal/pipewire/source.go new file mode 100644 index 0000000..8ce7841 --- /dev/null +++ b/internal/pipewire/source.go @@ -0,0 +1,484 @@ +package pipewire + +import ( + "context" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "log" + "os/exec" + "strconv" + "strings" + "sync" + "time" +) + +// Capture-jitter telemetry: log a stats line every readStatsInterval reads +// (avg/min/max ReadFull latency) plus an immediate warning whenever a single +// read exceeds readSpikeThreshold. pw-cat produces samples in real time, so +// the expected per-read latency is ~ChunkDurationMs (20ms). Spikes there are +// the most likely on-device cause of audible drops. +const ( + readSpikeThreshold = 40 * time.Millisecond + readStatsInterval = 250 // ~5s at 20ms chunks +) + +// Source implements the sendspin AudioSource interface via pw-cat --record. +// +// To avoid PipeWire's session manager auto-connecting our recorder to whatever +// the "default" source is (typically the mic on hardware that has one — which +// silently steals capture from a requested `.monitor` target), we start pw-cat +// with node.autoconnect=false and a unique node.name, then look up both nodes +// in pw-dump and pw-link the target's output ports to our input ports +// manually. +type Source struct { + target string + sampleRate int + channels int + + mu sync.Mutex + cmd *exec.Cmd + stdout io.ReadCloser + buf []byte + + // dataCh carries raw byte chunks from a background goroutine that + // drains pw-cat as fast as bytes appear. Read assembles ChunkDurationMs + // worth of samples from this stream — without this decoupling, Read + // would block ~21ms per call because PipeWire's default graph quantum + // (1024 samples at 48kHz) exceeds our 20ms tick budget, pushing the + // engine ~1.3ms behind real-time per chunk and eroding the 500ms + // client buffer-ahead until chunks land late and audio drops. + dataCh chan []byte + leftover []byte + stopDrain chan struct{} + drainErr error + drainMu sync.Mutex + + // jitter telemetry — accessed only from Read so no extra locking + readCount int + readSumNanos int64 + readMaxNanos int64 + readMinNanos int64 +} + +func NewSource(target string, sampleRate, channels int) *Source { + return &Source{ + target: target, + sampleRate: sampleRate, + channels: channels, + } +} + +func (s *Source) start() error { + uid := fmt.Sprintf("sendspin-cap-%d", time.Now().UnixNano()) + uidJSON, _ := json.Marshal(uid) + // node.latency requests a 20ms quantum (960 samples at 48kHz). Without + // this, PipeWire defaults to its graph quantum (typically 1024 samples + // / 21.33ms), which means pw-cat delivers in bursts that don't align + // with the sendspin engine's 20ms ticker — every chunk ends up 1.3ms + // over budget and the 500ms client buffer-ahead drifts away. + chunkSamples := (s.sampleRate * 20) / 1000 + props := fmt.Sprintf( + `{"node.name":%s,"node.description":"Sendspin capture","node.autoconnect":false,"node.latency":"%d/%d"}`, + uidJSON, chunkSamples, s.sampleRate, + ) + + args := []string{ + "--record", + "--raw", + "--format", "s32", + "--rate", strconv.Itoa(s.sampleRate), + "--channels", strconv.Itoa(s.channels), + "--target", s.target, + "--properties", props, + "-", + } + + log.Printf("pw-cat %s", strings.Join(args, " ")) + cmd := exec.Command("pw-cat", args...) + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("pw-cat stdout: %w", err) + } + stderr, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("pw-cat stderr: %w", err) + } + if err := cmd.Start(); err != nil { + return fmt.Errorf("pw-cat start: %w", err) + } + + go func(t string) { + buf, _ := io.ReadAll(stderr) + if msg := strings.TrimSpace(string(buf)); msg != "" { + log.Printf("pw-cat[%s] stderr: %s", t, msg) + } + }(s.target) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + outIDs, inIDs, linkErr := waitAndMatchPorts(ctx, s.target, uid) + if linkErr != nil { + _ = stdout.Close() + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + _ = cmd.Wait() + return fmt.Errorf("link target ports: %w", linkErr) + } + + for i := range outIDs { + lc := exec.Command("pw-link", strconv.Itoa(outIDs[i]), strconv.Itoa(inIDs[i])) + if out, err := lc.CombinedOutput(); err != nil { + log.Printf("pw-link %d→%d: %v: %s", outIDs[i], inIDs[i], err, out) + } + } + + s.cmd = cmd + s.stdout = stdout + s.buf = make([]byte, 4096) + + // Buffered channel sized to ~1s of audio (50 × 20ms chunks). Plenty + // of headroom for pw-cat's quantum bursts; if it ever fills, pw-cat + // blocks on its stdout write — at which point the engine is so far + // behind that backpressure is the right behavior. + s.dataCh = make(chan []byte, 50) + s.stopDrain = make(chan struct{}) + go s.drain(stdout, s.stopDrain) + + // Pre-buffer ~60ms (3 quanta) before letting Read return. Two quanta + // (~43ms) is the minimum to absorb the 1024-vs-960-sample beat + // pattern; 60ms gives a small safety margin for scheduler jitter + // without adding meaningful startup latency. + preBufferBytes := (60 * s.sampleRate / 1000) * 4 * s.channels + prebufStart := time.Now() + have := 0 + var queued [][]byte + for have < preBufferBytes { + select { + case b := <-s.dataCh: + queued = append(queued, b) + have += len(b) + case <-time.After(2 * time.Second): + _ = stdout.Close() + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + _ = cmd.Wait() + return fmt.Errorf("pw-cat pre-buffer timeout (got %d/%d bytes in %s)", + have, preBufferBytes, time.Since(prebufStart)) + } + } + // Re-prime the channel with what we already drained so Read sees + // the full pre-buffer. Channel capacity (50) > expected queue size + // (~5 slices), so this never blocks. + for _, b := range queued { + s.dataCh <- b + } + log.Printf("pw-cat pre-buffered %d bytes (%dms) in %s", + have, have/(48*4*2), time.Since(prebufStart).Round(time.Millisecond)) + return nil +} + +// drain continuously reads from pw-cat's stdout and pushes byte slices +// onto dataCh. Runs until stdout closes or stop is signaled. Each iteration +// allocates a fresh slice so the receiver owns it — pool/reuse here would +// race with Read. +func (s *Source) drain(stdout io.ReadCloser, stop <-chan struct{}) { + buf := make([]byte, 8192) + for { + n, err := stdout.Read(buf) + if n > 0 { + chunk := make([]byte, n) + copy(chunk, buf[:n]) + select { + case s.dataCh <- chunk: + case <-stop: + return + } + } + if err != nil { + s.drainMu.Lock() + s.drainErr = err + s.drainMu.Unlock() + close(s.dataCh) + return + } + select { + case <-stop: + return + default: + } + } +} + +func (s *Source) Read(samples []int32) (int, error) { + s.mu.Lock() + if s.cmd == nil { + if err := s.start(); err != nil { + s.mu.Unlock() + return 0, err + } + } + + need := len(samples) * 4 + if len(s.buf) < need { + s.buf = make([]byte, need) + } + buf := s.buf[:need] + s.mu.Unlock() + + readStart := time.Now() + filled := 0 + + // Drain any leftover bytes from the previous Read first. pw-cat's + // quantum (1024 samples) doesn't align with our request (960 samples), + // so every other call carries 64 samples' worth of bytes forward. + if len(s.leftover) > 0 { + copied := copy(buf[filled:], s.leftover) + filled += copied + s.leftover = s.leftover[copied:] + } + + for filled < need { + chunk, ok := <-s.dataCh + if !ok { + s.drainMu.Lock() + err := s.drainErr + s.drainMu.Unlock() + s.mu.Lock() + s.killUnlocked() + s.mu.Unlock() + if err == nil { + err = io.EOF + } + return filled / 4, err + } + copied := copy(buf[filled:], chunk) + filled += copied + if copied < len(chunk) { + s.leftover = chunk[copied:] + } + } + + s.recordReadTiming(time.Since(readStart), need) + + count := filled / 4 + // pw-cat emits full-range int32; sendspin's AudioSource contract is + // int32 samples right-justified to 24 bits (encodePCM packs the low + // three bytes; opus does `int16(s >> 8)`). Without this shift the + // signal's top byte is discarded and only low-byte dither survives, + // producing noise that tracks input amplitude. + for i := range count { + samples[i] = int32(binary.LittleEndian.Uint32(buf[i*4:i*4+4])) >> 8 + } + return count, nil +} + +// recordReadTiming accumulates per-read latency and emits both spike +// warnings and periodic stats summaries. The expected baseline is roughly +// the chunk duration the engine asks for, since pw-cat produces real-time +// samples and io.ReadFull blocks until that many bytes are available. +func (s *Source) recordReadTiming(dur time.Duration, byteCount int) { + d := dur.Nanoseconds() + s.readCount++ + s.readSumNanos += d + if d > s.readMaxNanos { + s.readMaxNanos = d + } + if s.readMinNanos == 0 || d < s.readMinNanos { + s.readMinNanos = d + } + + if dur > readSpikeThreshold { + log.Printf("pw-cat read spike: %s for %d bytes (threshold %s)", + dur.Round(time.Microsecond), byteCount, readSpikeThreshold) + } + + if s.readCount >= readStatsInterval { + avg := time.Duration(s.readSumNanos / int64(s.readCount)) + log.Printf("pw-cat read stats over %d reads: avg=%s min=%s max=%s", + s.readCount, + avg.Round(time.Microsecond), + time.Duration(s.readMinNanos).Round(time.Microsecond), + time.Duration(s.readMaxNanos).Round(time.Microsecond), + ) + s.readCount = 0 + s.readSumNanos = 0 + s.readMaxNanos = 0 + s.readMinNanos = 0 + } +} + +// killUnlocked tears down the running pw-cat without holding the mutex. +// Callers must hold s.mu. +func (s *Source) killUnlocked() { + if s.stopDrain != nil { + // Signal the drainer to exit. It will also exit on its own when + // stdout closes, but signaling first avoids a hung send into + // dataCh if the buffer is full at shutdown. + close(s.stopDrain) + s.stopDrain = nil + } + if s.stdout != nil { + s.stdout.Close() + s.stdout = nil + } + if s.cmd != nil && s.cmd.Process != nil { + s.cmd.Process.Kill() + s.cmd.Wait() + s.cmd = nil + } + s.leftover = nil +} + +func (s *Source) SampleRate() int { return s.sampleRate } +func (s *Source) Channels() int { return s.channels } +func (s *Source) Metadata() (string, string, string) { return "", "", "" } + +func (s *Source) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + s.killUnlocked() + return nil +} + +type portEntry struct { + id int + channel string +} + +type dumpObject struct { + ID int `json:"id"` + Type string `json:"type"` + Info json.RawMessage `json:"info"` +} + +type dumpNodeInfo struct { + Props json.RawMessage `json:"props"` +} + +type dumpPortInfo struct { + Direction string `json:"direction"` + Props json.RawMessage `json:"props"` +} + +// waitAndMatchPorts polls pw-dump until both the target node and our pw-cat +// node are present, then pairs target output ports to our input ports. +func waitAndMatchPorts(ctx context.Context, targetName, ourName string) (outIDs, inIDs []int, err error) { + for { + select { + case <-ctx.Done(): + return nil, nil, ctx.Err() + default: + } + + raw, dumpErr := exec.Command("pw-dump").Output() + if dumpErr != nil { + time.Sleep(100 * time.Millisecond) + continue + } + var objects []dumpObject + if err := json.Unmarshal(raw, &objects); err != nil { + time.Sleep(100 * time.Millisecond) + continue + } + + nodeIDs := make(map[string]int) + for _, obj := range objects { + if obj.Type != "PipeWire:Interface:Node" { + continue + } + var info dumpNodeInfo + if json.Unmarshal(obj.Info, &info) != nil { + continue + } + var props map[string]any + if json.Unmarshal(info.Props, &props) != nil { + continue + } + if name, ok := props["node.name"].(string); ok { + nodeIDs[name] = obj.ID + } + } + + targetID, targetOK := nodeIDs[targetName] + ourID, ourOK := nodeIDs[ourName] + if !targetOK || !ourOK { + time.Sleep(100 * time.Millisecond) + continue + } + + var targetOuts, ourIns []portEntry + for _, obj := range objects { + if obj.Type != "PipeWire:Interface:Port" { + continue + } + var info dumpPortInfo + if json.Unmarshal(obj.Info, &info) != nil { + continue + } + var props map[string]any + if json.Unmarshal(info.Props, &props) != nil { + continue + } + nodeIDf, ok := props["node.id"].(float64) + if !ok { + continue + } + nodeID := int(nodeIDf) + ch, _ := props["audio.channel"].(string) + + switch { + case nodeID == targetID && info.Direction == "output": + targetOuts = append(targetOuts, portEntry{obj.ID, ch}) + case nodeID == ourID && info.Direction == "input": + ourIns = append(ourIns, portEntry{obj.ID, ch}) + } + } + + if len(targetOuts) == 0 || len(ourIns) == 0 { + time.Sleep(100 * time.Millisecond) + continue + } + + pairs := matchPorts(targetOuts, ourIns) + if len(pairs) == 0 { + return nil, nil, fmt.Errorf("no matching ports between %q and %q", targetName, ourName) + } + for _, p := range pairs { + outIDs = append(outIDs, p[0]) + inIDs = append(inIDs, p[1]) + } + return outIDs, inIDs, nil + } +} + +// matchPorts pairs output ports to input ports by audio.channel name, +// falling back to positional order when channel names are missing or unmatched. +func matchPorts(outs, ins []portEntry) [][2]int { + inByChannel := make(map[string]int, len(ins)) + for _, p := range ins { + if p.channel != "" { + inByChannel[p.channel] = p.id + } + } + + used := make(map[int]bool) + var result [][2]int + for _, o := range outs { + if inID, ok := inByChannel[o.channel]; ok && !used[inID] { + result = append(result, [2]int{o.id, inID}) + used[inID] = true + } + } + if len(result) > 0 { + return result + } + + for i := range min(len(outs), len(ins)) { + result = append(result, [2]int{outs[i].id, ins[i].id}) + } + return result +} diff --git a/live-server-spec.md b/live-server-spec.md new file mode 100644 index 0000000..7f43cd5 --- /dev/null +++ b/live-server-spec.md @@ -0,0 +1,211 @@ +# Live-Audio Sendspin Server — Design Spec + +A Sendspin server that captures audio from any PipeWire source via `pw-cat` and streams it to a configurable set of Sendspin speaker clients. Controlled at runtime from Home Assistant over MQTT; configured (group definitions) over HTTP. + +This server coexists with other Sendspin servers (e.g., Music Assistant). It claims and releases speakers using the protocol's multi-server arbitration described in [sendspin-spec.md](sendspin-spec.md). + +## Goals + +- Stream any local PipeWire source (line-in, USB capture, application output, virtual loopback) to a set of speakers in sync. +- Let Home Assistant pick which preset of speakers is active, start/stop playback, and set group volume — without HA needing to speak Sendspin. +- Keep group/preset configuration out of HA (HTTP/web UI for that). +- Cooperate cleanly with other Sendspin servers: claim speakers only when active, release them when not. + +## Non-goals + +- Multi-room presence-based muting. That belongs in the speaker client ([internal/pipewire/](internal/pipewire/)) — speaker mutes itself locally on MQTT command and reports state via `client/state`. +- Music library, playlists, metadata search. This server only ferries live PipeWire audio. +- Sendspin roles other than `player`. No metadata/artwork/visualizer/color generation. + +## Components + +``` + ┌──────────────────────────────────────┐ + │ live-audio server │ + HTTP (config) ◀────┤ │ + │ ┌────────────┐ ┌──────────────┐ │ + MQTT (control) ◀───┤ │ group mgr │───▶│ sendspin srv │──┼──▶ speakers + │ └────────────┘ └──────────────┘ │ (mDNS-discovered) + │ │ ▲ │ + │ ▼ │ │ + │ ┌──────────┐ ┌───────────┐ │ + │ │ pw-cat │─PCM─▶│ encoder │ │ + │ └──────────┘ └───────────┘ │ + └──────────────────────────────────────┘ +``` + +1. **Sendspin server core** — implements the Sendspin protocol (server-initiated mode), browses mDNS for `_sendspin._tcp`, holds WS connections, encodes audio per client, sends timestamped chunks. +2. **Audio source** — `pw-cat --capture --target ` producing PCM to the encoder. One active capture at a time. +3. **Group manager** — owns the set of configured presets (which speakers, which PipeWire source). Knows which preset is "active" and drives connect/disconnect/start/stop transitions. +4. **HTTP server** — CRUD for presets, lookup endpoints for discovered speakers and available PipeWire sources, optional static web UI. +5. **MQTT bridge** — translates HA commands to group-manager actions; publishes state back as retained messages with HA MQTT discovery payloads. + +## Concepts + +### Preset + +A preset is a named configuration that maps **one PipeWire source** to **a set of speaker clients**. Presets are user-defined and persisted to disk. + +```json +{ + "id": "kitchen-radio", + "name": "Kitchen Radio", + "source": "alsa_input.usb-AudioCapture.pro-input-0", + "members": ["speaker-living-room", "speaker-kitchen"] +} +``` + +- `id`: stable slug used in MQTT/HTTP. Lowercase, kebab-case. +- `members`: Sendspin `client_id` values, not friendly names. + +### Active preset and playback state + +Exactly **zero or one** preset is active at a time. + +| State | Active preset | WS connections | pw-cat running | Streaming | +|------|---------------|----------------|----------------|-----------| +| `off` | none | closed | no | no | +| `paused` | one | open (claim held) | no | no (`stream/end` sent) | +| `playing` | one | open (claim held) | yes | yes (`stream/start` + audio chunks) | + +Transitions are driven by MQTT commands and by configuration changes (e.g., removing a member from the active preset). + +### Multi-server arbitration + +Per [sendspin-spec.md §Multiple Servers](sendspin-spec.md): + +- When entering `paused` or `playing`, the server connects (or reconnects) to each member with `connection_reason: 'playback'`. This wins against any other server holding the speaker. +- When leaving the active state (going to `off`, or removing a member), the server closes the WebSocket. The speaker becomes available for other servers' next claim attempt. +- While paused, the server keeps the WS open with `'playback'` reason — fast resume, but another server using `'playback'` (e.g., Music Assistant pressing play) can still preempt it. That's the correct cooperative behavior. + +## MQTT interface + +### Connection + +- Broker URL, username, password supplied via CLI flags / env vars. +- Discovery topic prefix: `homeassistant` (configurable). +- Base topic: `sendspin/live` (configurable per server instance, so multiple live-audio servers can coexist). +- LWT: `sendspin/live/availability` = `offline` (retained), set to `online` after connect. + +### State topics (retained, published by server) + +| Topic | Payload | Notes | +|------|---------|-------| +| `sendspin/live/availability` | `online` \| `offline` | LWT-backed. | +| `sendspin/live/active_preset` | preset `id` or `""` | Empty string means `off`. | +| `sendspin/live/playback` | `playing` \| `paused` \| `off` | | +| `sendspin/live/volume` | `0`–`100` | Group volume; see [sendspin-spec.md §Setting group volume](sendspin-spec.md#switch-command-cycle). | +| `sendspin/live/presets` | JSON array of `{id,name}` | Updated when presets change. Lets HA refresh its select options. | + +### Command topics (subscribed by server) + +| Topic | Payload | Effect | +|------|---------|--------| +| `sendspin/live/active_preset/set` | preset `id` or `""` | Switch active preset. `""` → `off`. Implies a state transition; preserves last `playback` state when possible (see below). | +| `sendspin/live/playback/set` | `play` \| `pause` \| `stop` | `play`: requires an active preset; goes to `playing`. `pause`: keep claim, stop stream. `stop`: alias for setting `active_preset` to `""`. | +| `sendspin/live/volume/set` | `0`–`100` | Sets group volume on the active preset's members. No-op if `off`. | + +### Active preset switching semantics + +- `off` → preset X: claim members, remain in `paused` (don't auto-play; HA can issue `play` next). Rationale: avoid surprise audio when the user is just selecting a preset. +- preset X → preset Y (both non-empty): release X's exclusive members, claim Y's, carry over the playback state (`playing` stays `playing`, `paused` stays `paused`). Members present in both stay connected. +- preset X → `off`: send `stream/end` if playing, close all WS connections, go to `off`. + +### HA MQTT discovery + +On connect, publish discovery configs (retained) so HA auto-creates entities: + +- A `select` entity bound to `sendspin/live/active_preset` / `…/set`, with options taken from the `presets` topic. +- A `switch` or `button` set for `playback/set` — typically a `media_player` is overkill; a simple `select` (`playing`/`paused`/`off`) or two `button`s (`play`, `pause`) plus current-state `sensor` is enough. +- A `number` entity for `volume`. +- A `binary_sensor` mirroring `availability`. + +Discovery topics follow `//sendspin_live//config`. Republish all discovery configs on every connect (idempotent, retained). + +## HTTP interface + +Listens on a configurable port (default `8080`). Serves both a REST API and an optional static web UI for editing presets. + +### Endpoints + +| Method | Path | Purpose | +|-------|------|---------| +| `GET` | `/api/presets` | List all presets. | +| `POST` | `/api/presets` | Create. Body: preset JSON without `id` (server generates) or with `id` (server validates uniqueness). | +| `GET` | `/api/presets/{id}` | Read. | +| `PUT` | `/api/presets/{id}` | Replace. If `id` is the active preset, apply changes live. | +| `DELETE` | `/api/presets/{id}` | Delete. If active, transitions server to `off` first. | +| `GET` | `/api/clients` | List Sendspin clients currently discovered via mDNS. Returns `[{client_id, name, connected, last_seen}]`. Lets the UI build a member picker. | +| `GET` | `/api/sources` | List PipeWire sources (wraps `pw-dump` like [internal/pipewire/nodes.go](internal/pipewire/nodes.go)). Returns `[{name, description}]`. | +| `GET` | `/api/state` | Current `{active_preset, playback, volume, members: [{client_id, connected, muted, volume}]}`. Read-only mirror of MQTT state plus per-member detail. | +| `GET` | `/` | Static web UI (optional, single-page). | + +### Persistence + +Presets stored as a single JSON file (default `~/.config/sendspin-live/presets.json`, overridable via `--config`). Write-on-change with atomic rename. No database. + +### Auth + +None by default — assumes trusted LAN. A future flag could add HTTP basic auth; out of scope for v1. + +## Audio pipeline + +- Capture: `pw-cat --capture --target --rate 48000 --channels 2 --format s32 -` to stdout. Reuse [internal/pipewire/source.go](internal/pipewire/source.go). +- One capture process per active preset. Stop it on transition to `paused`/`off`. Restart on transition into `playing`. +- Encoding: per-client encoders (Opus by default, FLAC/PCM as per `client/hello` capabilities) — handled by the upstream `sendspin-go` library; this server just feeds PCM frames. +- Sample rate / channels: configurable via CLI flags, same as the existing simple server. v1 does not switch capture rate based on preset. + +## Process model + +Single Go binary. Goroutines for: + +- mDNS browse loop. +- Sendspin server (HTTP upgrade + per-client goroutines, provided by `sendspin-go`). +- `pw-cat` reader, owned by the group manager and recreated per playback start. +- HTTP server. +- MQTT client (paho or similar) with reconnect. + +Group manager is the single source of truth for state. All state transitions go through it (channel- or mutex-serialized) to keep MQTT, sendspin core, and pw-cat in agreement. + +## CLI + +``` +sendspin-live-server [flags] + + --port int Sendspin WS port (default 8927) + --name string advertised server name (default "Live Audio") + --http-port int HTTP/API port (default 8080) + --config string presets file path (default ~/.config/sendspin-live/presets.json) + --mqtt-url string e.g. tcp://broker.local:1883 (required to enable MQTT bridge) + --mqtt-username string + --mqtt-password string + --mqtt-base string base topic (default "sendspin/live") + --mqtt-discovery string HA discovery prefix (default "homeassistant") + --rate int capture sample rate (default 48000) + --channels int capture channels (default 2) + --log-level string debug|info|warn|error (default info) +``` + +`--mqtt-url` empty disables the MQTT bridge entirely; HTTP still works. + +## Layout (proposed) + +``` +cmd/live-server/main.go # flags, wiring, signal handling +internal/live/ + manager.go # group manager, state machine + preset.go # preset type + JSON persistence + http.go # REST handlers + static UI + mqtt.go # MQTT bridge + HA discovery +internal/pipewire/ # existing — source.go reused +web/ # static SPA assets (optional) +``` + +The existing simple [cmd/server/main.go](cmd/server/main.go) stays as-is for one-source-one-server use; the live server is a separate binary. + +## Open questions + +- **Per-preset capture format**: v1 keeps one global rate/channels. Worth revisiting if any source is mono or 44.1k native. +- **Preset transitions and group identity**: should two presets sharing the same members + source map to the same Sendspin `group_id`, or always get a fresh group on each activation? Fresh group is simpler and avoids stale `group_name` confusion. +- **Per-member volume in MQTT**: v1 exposes group volume only. Individual speakers are muted/volumed via their own MQTT topics (separate concern on the speaker client). +- **Speaker discovery while inactive**: server should still browse mDNS in `off` state so `/api/clients` is useful for editing presets. Browsing != connecting. diff --git a/out.raw b/out.raw new file mode 100644 index 0000000000000000000000000000000000000000..596b8d910d94fc9c2c77493ba162487d1ef98c54 GIT binary patch literal 3080192 zcmeIus}2G&5Cu>kOOQAu5{E#n;BzntJ_v!~gAgP*Boc?@v9N@0Mv%Y~(5Ip|Z70+A zG#b^Io$7OaY^H+op;env7yYWPpXX(_ET5-&@1AngzopEE*OaT#CFLYPzpP_F>VD1n zph*4VzPSHe#(U#O@!xP+?Az(6*kx~Xy6vR>$mk;p5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U yAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0Rja66L@` (e.g., `player@v1`, `controller@v1`). + +This specification defines the following roles: [`player`](#player-messages), [`controller`](#controller-messages), [`metadata`](#metadata-messages), [`artwork`](#artwork-messages), [`visualizer`](#visualizer-messages), [`color`](#color-messages). All servers must implement all versions of these roles described in this specification. + +All role names and versions not starting with `_` are reserved for future revisions of this specification. + +### Priority and Activation + +Clients list roles in `supported_roles` in priority order (most preferred first). If a client supports multiple versions of a role, all should be listed: `["player@v2", "player@v1"]`. + +The server activates one version per role family (e.g., one `player@vN`, one `controller@vN`)—the first match it implements from the client's list. The server reports activated roles in `active_roles`. + +Message object keys (e.g., `player?`, `controller?`) use unversioned role names. The server determines the appropriate version from the client's `active_roles`. + +### Detecting Outdated Servers + +Servers should track when clients request roles or role versions they don't implement (excluding those starting with `_`). This indicates the client supports a newer version of the specification and the server needs to be updated. + +### Application-Specific Roles + +Custom roles outside the specification start with `_` (e.g., `_myapp_controller`, `_custom_display`). Application-specific roles can also be versioned: `_myapp_visualizer@v2`. + +## Establishing a Connection + +Sendspin has two standard ways to establish connections: Server and Client initiated. Server Initiated connections are recommended as they provide standardized multi-server behavior, but require mDNS which may not be available in all environments. + +Sendspin Servers must support both methods described below. + +### Server Initiated Connections + +Clients announce their presence via mDNS using: +- Service type: `_sendspin._tcp.local.` +- Port: The port the Sendspin client is listening on (recommended: `8928`) +- TXT record: `path` key specifying the WebSocket endpoint (recommended: `/sendspin`) +- TXT record: `name` key specifying the friendly name of the player (optional) + +The server discovers available clients through mDNS and connects to each client via WebSocket using the advertised address and path. + +**Note:** Do not manually connect to servers if you are advertising `_sendspin._tcp`. + +#### Multiple Servers + +In environments with multiple Sendspin servers, servers may need to reconnect to clients when starting playback to reclaim them. The [`server/hello`](#server--client-serverhello) message includes a `connection_reason` field indicating whether the server is connecting for general availability (`'discovery'`) or for active/upcoming playback (`'playback'`). + +Clients can only be connected to one server at a time. Clients must persistently store the `server_id` of the server that most recently had `playback_state: 'playing'` (the "last played server"). + +When a second server connects, clients must: + +1. **Accept incoming connections**: Complete the handshake (send [`client/hello`](#client--server-clienthello), receive [`server/hello`](#server--client-serverhello)) with the new server before making any decisions. + +2. **Decide which server to keep**: + - If the new server's `connection_reason` is `'playback'` → switch to new server + - If the new server's `connection_reason` is `'discovery'` and the existing server connected with `'playback'` → keep existing server + - If both servers have `connection_reason: 'discovery'`: + - Prefer the server matching the stored last played server + - If neither matches (or no history), keep the existing server + +3. **Disconnect**: Send [`client/goodbye`](#client--server-clientgoodbye) with reason `'another_server'` to the server being disconnected, then close the connection. + +### Client Initiated Connections + +If clients prefer to initiate the connection instead of waiting for the server to connect, the server must be discoverable via mDNS using: +- Service type: `_sendspin-server._tcp.local.` +- Port: The port the Sendspin server is listening on (recommended: `8927`) +- TXT record: `path` key specifying the WebSocket endpoint (recommended: `/sendspin`) +- TXT record: `name` key specifying the friendly name of the server (optional) + +Clients discover the server through mDNS and initiate a WebSocket connection using the advertised address and path. + +**Note:** Do not advertise `_sendspin._tcp` if the client plans to initiate the connection. + +#### Multiple Servers + +Unlike server-initiated connections, servers cannot reclaim clients by reconnecting. How clients handle multiple discovered servers, server selection, and switching is implementation-defined. + +**Note:** After this point, Sendspin works independently of how the connection was established. The Sendspin client is always the consumer of data like audio or metadata, regardless of who initiated the connection. + +While custom connection methods are possible for specialized use cases (like remotely accessible web-browsers, mobile apps), most clients should use one of the two standardized methods above if possible. + +## Communication + +Once the connection is established, Client and Server are going to talk. + +The first message must always be a `client/hello` message from the client to the server. +Once the server receives this message, it responds with a `server/hello` message. Before this handshake is complete, no other messages should be sent. + +WebSocket text messages are used to send JSON payloads. + +**Note:** In field definitions, `?` indicates an optional field (e.g., `field?`: type means the field may be omitted). + +All messages have a `type` field identifying the message and a `payload` object containing message-specific data. The payload structure varies by message type and is detailed in each message section below. + +Message format example: + +```json +{ + "type": "stream/start", + "payload": { + "player": { + "codec": "opus", + "sample_rate": 48000, + "channels": 2, + "bit_depth": 16 + }, + "artwork": { + "channels": [ + { + "source": "album", + "format": "jpeg", + "width": 800, + "height": 800 + } + ] + } + } +} +``` + +WebSocket binary messages are used to send audio chunks, media art, and visualization data. The first byte is a uint8 representing the message type. + +### Binary Message ID Structure + +Binary message IDs typically use **bits 7-2** for role type and **bits 1-0** for message slot, allocating 4 IDs per role. Roles with expanded allocations use **bits 2-0** for message slot (8 IDs). + +**Role assignments:** +- `000000xx` (0-3): Reserved for future use +- `000001xx` (4-7): Player role +- `000010xx` (8-11): Artwork role +- `000011xx` (12-15): Reserved for a future role +- `00010xxx` (16-23): Visualizer role +- Roles 6-47 (IDs 24-191): Reserved for future roles +- Roles 48-63 (IDs 192-255): Available for use by [application-specific roles](#application-specific-roles) + +**Message slots:** +- Slot 0: `xxxxxx00` +- Slot 1: `xxxxxx01` +- Slot 2: `xxxxxx10` +- Slot 3: `xxxxxx11` + +Roles with expanded allocations have slots 0-7. + +**Note:** Role versions share the same binary message IDs (e.g., `player@v1` and `player@v2` both use IDs 4-7). + +## Clock Synchronization + +Clients continuously send `client/time` messages to maintain an accurate offset from the server's clock. The frequency of these messages is determined by the client based on network conditions and clock stability. + +Binary audio messages contain timestamps in the server's time domain indicating when the audio should be played. Clients must use the [time-filter](https://github.com/Sendspin-Protocol/time-filter) algorithm to translate server timestamps to their local clock for synchronized playback. The time filter is a two-dimensional Kalman filter that tracks both clock offset and drift. See the [time-filter](https://github.com/Sendspin-Protocol/time-filter) repository for a C++ reference implementation and [aiosendspin](https://github.com/Sendspin-Protocol/aiosendspin/blob/main/aiosendspin/client/time_sync.py) for a Python implementation. + +Each [`server/time`](#server--client-servertime) response provides the four timestamps needed by the filter: the client's transmitted timestamp, the server's received timestamp, the server's transmitted timestamp, and the client's receive time (captured locally when the response arrives). Clients feed these into the time filter via its `update` method and use its `compute_client_time` method to convert server timestamps to local clock values for playback scheduling. + +## Playback Synchronization + +- Each client is responsible for maintaining synchronization with the server's timestamps +- Clients maintain accurate sync by adding or removing samples using interpolation to compensate for clock drift +- When a client cannot maintain sync (e.g., buffer underrun), it should send `state: 'error'` via [`client/state`](#client--server-clientstate), mute its audio output, and continue buffering until it can resume synchronized playback, at which point it should send `state: 'synchronized'` +- The server is unaware of individual client synchronization accuracy - it simply broadcasts timestamped audio +- The server sends audio to late-joining clients with future timestamps only, allowing them to buffer and start playback in sync with existing clients +- Audio chunks may arrive with timestamps in the past due to network delays or buffering; clients should drop these late chunks to maintain sync +- Clients subtract their [`static_delay_ms`](#client--server-clientstate-player-object) from server timestamps before scheduling playback +- Servers factor in each client's `static_delay_ms` when calculating how far ahead to send audio, keeping effective buffer headroom constant + +```mermaid +sequenceDiagram + participant Client + participant Server + + Note over Client,Server: WebSocket connection established + + Note over Client,Server: Text messages = JSON payloads, Binary messages = Audio/Art/Visualization + + Client->>Server: client/hello (roles and capabilities) + Server->>Client: server/hello (server info, connection_reason) + + Client->>Server: client/state (state: synchronized) + alt Player role + Client->>Server: client/state (player: volume, muted) + end + + loop Continuous clock sync + Client->>Server: client/time (client clock) + Server->>Client: server/time (timing + offset info) + end + + alt Stream starts + Server->>Client: stream/start (codec, format details) + end + + Server->>Client: group/update (playback_state, group_id, group_name) + Server->>Client: server/state (metadata, controller, color) + + loop During playback + alt Player role + Server->>Client: binary Type 4 (audio chunks with timestamps) + end + alt Artwork role + Server->>Client: binary Types 8-11 (artwork channels 0-3) + end + alt Visualizer role + Server->>Client: binary Type 16 (visualization data) + end + end + + alt Player requests format change + Client->>Server: stream/request-format (codec, sample_rate, etc) + Server->>Client: stream/start (player: new format) + end + + alt Seek operation + Server->>Client: stream/clear (roles: [player, visualizer]) + end + + alt Controller role + Client->>Server: client/command (controller: play/pause/volume/switch/etc) + end + + alt State changes + Client->>Server: client/state (state and/or player changes) + end + + alt Server commands player + Server->>Client: server/command (player: volume, mute) + end + + Server->>Client: stream/end (ends all role streams) + + alt Graceful disconnect + Client->>Server: client/goodbye (reason) + Note over Client,Server: Server initiates disconnect + end +``` + +## Core messages +This section describes the fundamental messages that establish communication between clients and the server. These messages handle initial handshakes, ongoing clock synchronization, stream lifecycle management, and role-based state updates and commands. + +Every Sendspin client and server must implement all messages in this section regardless of their specific roles. Role-specific object details are documented in their respective role sections and need to be implemented only if the client supports that role. + +### Client → Server: `client/hello` + +First message sent by the client after establishing the WebSocket connection. Contains information about the client's capabilities and roles. +This message will be followed by a [`server/hello`](#server--client-serverhello) message from the server. + +Players that can output audio should have the role `player`. + +- `client_id`: string - uniquely identifies the client for groups and de-duplication. Should remain persistent across reconnections so servers can associate clients with previous sessions (e.g., remembering group membership, settings, playback queue) +- `name`: string - friendly name of the client +- `device_info?`: object - optional information about the device + - `product_name?`: string - device model/product name + - `manufacturer?`: string - device manufacturer name + - `software_version?`: string - software version of the client (not the Sendspin version) +- `version`: integer (must be `1`) - version of the core message format that the Sendspin client implements (independent of role versions) +- `supported_roles`: string[] - versioned roles supported by the client (e.g., `player@v1`, `controller@v1`). Defined versioned roles are: + - `player@v1` - outputs audio + - `controller@v1` - controls the current Sendspin group + - `metadata@v1` - displays text metadata describing the currently playing audio + - `artwork@v1` - displays artwork images + - `visualizer@v1` - visualizes audio + - `color@v1` - receives colors derived from the current audio +- `player@v1_support?`: object - only if `player@v1` is listed ([see player@v1 support object details](#client--server-clienthello-playerv1-support-object)) +- `artwork@v1_support?`: object - only if `artwork@v1` is listed ([see artwork@v1 support object details](#client--server-clienthello-artworkv1-support-object)) +- `visualizer@v1_support?`: object - only if `visualizer@v1` is listed ([see visualizer@v1 support object details](#client--server-clienthello-visualizerv1-support-object)) + +**Note:** Each role version may have its own support object (e.g., `player@v1_support`, `player@v2_support`). Application-specific roles or role versions follow the same pattern (e.g., `_myapp_display@v1_support`, `player@_experimental_support`). + +### Client → Server: `client/time` + +Sends current internal clock timestamp (in microseconds) to the server. +Once received, the server responds with a [`server/time`](#server--client-servertime) message containing timing information to establish clock offsets. + +- `client_transmitted`: integer - client's internal clock timestamp in microseconds + +### Server → Client: `server/hello` + +Response to the [`client/hello`](#client--server-clienthello) message with information about the server. + +Only after receiving this message should the client send any other messages (including [`client/time`](#client--server-clienttime) and the initial [`client/state`](#client--server-clientstate) message if the client has roles that require state updates). + +- `server_id`: string - identifier of the server +- `name`: string - friendly name of the server +- `version`: integer (must be `1`) - version of the core message format that the server implements (independent of role versions) +- `active_roles`: string[] - versioned roles that are active for this client (e.g., `player@v1`, `controller@v1`) +- `connection_reason`: 'discovery' | 'playback' - only used for [server-initiated connections](#multiple-servers) + - `discovery` - server is connecting for general availability (e.g., initial discovery, reconnection after connection loss) + - `playback` - server needs client for active or upcoming playback + +**Note:** Servers will always activate the client's [preferred](#priority-and-activation) version of each role. Checking `active_roles` is only necessary to detect outdated servers or confirm activation of [application-specific roles](#application-specific-roles). + +### Server → Client: `server/time` + +Response to the [`client/time`](#client--server-clienttime) message with timestamps to establish clock offsets. + +For synchronization, all timing is relative to the server's monotonic clock. These timestamps have microsecond precision and are not necessarily based on epoch time. + +- `client_transmitted`: integer - client's internal clock timestamp received in the `client/time` message +- `server_received`: integer - timestamp that the server received the `client/time` message in microseconds +- `server_transmitted`: integer - timestamp that the server transmitted this message in microseconds + +### Client → Server: `client/state` + +Client sends state updates to the server. Contains client-level state and role-specific state objects. + +Must be sent immediately after receiving [`server/hello`](#server--client-serverhello), and whenever any state changes thereafter. + +For the initial message, include all state fields. For subsequent updates, only include fields that have changed. The server will merge these updates into existing state. + +- `state`: 'synchronized' | 'error' | 'external_source' - operational state of the client + - `'synchronized'` - client is operational and synchronized with server timestamps + - `'error'` - client has a problem preventing normal operation (unable to keep up, clock sync issues, etc.) + - `'external_source'` - client is in use by an external system and is not currently participating in Sendspin playback with this server. See [External Source Handling](#external-source-handling) +- `player?`: object - only if client has `player` role ([see player state object details](#client--server-clientstate-player-object)) + +[Application-specific roles](#application-specific-roles) may also include objects in this message (keys starting with `_`). + +### External Source Handling + +When a client sets `state: 'external_source'`, it indicates the client's output is in use by an external system (e.g., a different audio source, HDMI input, or local media playback) and is not currently participating in Sendspin playback with this server. + +#### Server behavior when `state` changes to `'external_source'`: + +If the client is in a multi-client group: +1. Remember the client's current group as its "previous group" (see [switch command cycle](#switch-command-cycle)) +2. Move the client to a new solo group (stopped) + - Send [`group/update`](#server--client-groupupdate) with the new group information + - Send [`stream/end`](#server--client-streamend) for all active streams + +If the client is already in a solo group: +- Stop playback and send [`stream/end`](#server--client-streamend) for all active streams + +### Client → Server: `client/command` + +Client sends commands to the server. Contains command objects based on the client's supported roles. + +- `controller?`: object - only if client has `controller` role ([see controller command object details](#client--server-clientcommand-controller-object)) + +[Application-specific roles](#application-specific-roles) may also include objects in this message (keys starting with `_`). + +### Server → Client: `server/state` + +Server sends state updates to the client. Contains role-specific state objects. + +Only include fields that have changed. The client will merge these updates into existing state. Fields set to `null` should be cleared from the client's state. + +- `metadata?`: object - only sent to clients with `metadata` role ([see metadata state object details](#server--client-serverstate-metadata-object)) +- `controller?`: object - only sent to clients with `controller` role ([see controller state object details](#server--client-serverstate-controller-object)) +- `color?`: object - only sent to clients with `color` role ([see color state object details](#server--client-serverstate-color-object)) + +[Application-specific roles](#application-specific-roles) may also include objects in this message (keys starting with `_`). + +### Server → Client: `server/command` + +Server sends commands to the client. Contains role-specific command objects. + +- `player?`: object - only sent to clients with `player` role ([see player command object details](#server--client-servercommand-player-object)) + +[Application-specific roles](#application-specific-roles) may also include objects in this message (keys starting with `_`). + +### Server → Client: `stream/start` + +Starts a stream for one or more roles. If sent for a role that already has an active stream, updates the stream configuration without clearing buffers. + +- `player?`: object - only sent to clients with the `player` role ([see player object details](#server--client-streamstart-player-object)) +- `artwork?`: object - only sent to clients with the `artwork` role ([see artwork object details](#server--client-streamstart-artwork-object)) +- `visualizer?`: object - only sent to clients with the `visualizer` role ([see visualizer object details](#server--client-streamstart-visualizer-object)) + +[Application-specific roles](#application-specific-roles) may also include objects in this message (keys starting with `_`). + +### Server → Client: `stream/clear` + +Instructs clients to clear buffers without ending the stream. Used for seek operations. + +- `roles?`: string[] - which roles to clear: '[player](#server--client-streamclear-player)', '[visualizer](#server--client-streamclear-visualizer)', or both. If omitted, clears both roles + +[Application-specific roles](#application-specific-roles) may also be included in this array (names starting with `_`). + +### Client → Server: `stream/request-format` + +Request different stream format (upgrade or downgrade). Available for clients with the `player` or `artwork` role. + +- `player?`: object - only for clients with the `player` role ([see player object details](#client--server-streamrequest-format-player-object)) +- `artwork?`: object - only for clients with the `artwork` role ([see artwork object details](#client--server-streamrequest-format-artwork-object)) + +[Application-specific roles](#application-specific-roles) may also include objects in this message (keys starting with `_`). + +Response: [`stream/start`](#server--client-streamstart) for the requested role(s) with the new format. + +**Note:** Clients should use this message to adapt to changing network conditions, CPU constraints, or display requirements. The server maintains separate encoding for each client, allowing heterogeneous device capabilities within the same group. + +### Server → Client: `stream/end` + +Ends the stream for one or more roles. When received, clients should stop output and clear buffers for the specified roles. + +- `roles?`: string[] - roles to end streams for ('player', 'artwork', 'visualizer'). If omitted, ends all active streams + +[Application-specific roles](#application-specific-roles) may also be included in this array (names starting with `_`). + +### Server → Client: `group/update` + +State update of the group this client is part of. + +Contains delta updates with only the changed fields. The client should merge these updates into existing state. Fields set to `null` should be cleared from the client's state. + +- `playback_state?`: 'playing' | 'stopped' - playback state of the group +- `group_id?`: string - group identifier +- `group_name?`: string - friendly name of the group + +### Client → Server: `client/goodbye` + +Sent by the client before gracefully closing the connection. This allows the client to inform the server why it is disconnecting. + +Upon receiving this message, the server should initiate the disconnect. + +- `reason`: 'another_server' | 'shutdown' | 'restart' | 'user_request' + - `another_server` - client is switching to a different Sendspin server. Server should not auto-reconnect but should show the client as available for future playback + - `shutdown` - client is shutting down. Server should not auto-reconnect + - `restart` - client is restarting and will reconnect. Server should auto-reconnect + - `user_request` - user explicitly requested to disconnect from this server. Server should not auto-reconnect + +**Note:** Clients may close the connection without sending this message (e.g., crash, network loss), or immediately after sending `client/goodbye` without waiting for the server to disconnect. When a client disconnects without sending `client/goodbye`, servers should assume the disconnect reason is `restart` and attempt to auto-reconnect. + +## Player messages +This section describes messages specific to clients with the `player` role, which handle audio output and synchronized playback. Player clients receive timestamped audio data, manage their own volume and mute state, and can request different audio formats based on their capabilities and current conditions. + +**Note:** Volume values (0-100) represent perceived loudness, not linear amplitude (e.g., volume 50 should be perceived as half as loud as volume 100). Players must convert these values to appropriate amplitude for their audio hardware. + +### Client → Server: `client/hello` player@v1 support object + +The `player@v1_support` object in [`client/hello`](#client--server-clienthello) has this structure: + +- `player@v1_support`: object + - `supported_formats`: object[] - list of supported audio formats in priority order (first is preferred) + - `codec`: 'opus' | 'flac' | 'pcm' - codec identifier + - `channels`: integer - supported number of channels (e.g., 1 = mono, 2 = stereo) + - `sample_rate`: integer - sample rate in Hz (e.g., 44100) + - `bit_depth`: integer - bit depth for this format (e.g., 16, 24) + - `buffer_capacity`: integer - max size in bytes of compressed audio messages in the buffer that are yet to be played + - `supported_commands`: string[] - subset of: 'volume', 'mute' + +**Note:** Servers must support all audio codecs: 'opus', 'flac', and 'pcm'. + +**PCM Encoding Convention:** For the `pcm` codec, samples are encoded as little-endian signed integers (two's complement). 24-bit samples are packed as 3 bytes per sample. + +### Client → Server: `client/state` player object + +The `player` object in [`client/state`](#client--server-clientstate) has this structure: + +Informs the server of player-specific state changes. Only for clients with the `player` role. + +State updates must be sent whenever any state changes, including when the volume was changed through a `server/command` or via device controls. + +- `player`: object + - `volume?`: integer - range 0-100, must be included if 'volume' is in `supported_commands` from [`player@v1_support`](#client--server-clienthello-playerv1-support-object) + - `muted?`: boolean - mute state, must be included if 'mute' is in `supported_commands` from [`player@v1_support`](#client--server-clienthello-playerv1-support-object) + - `static_delay_ms`: integer - static delay in milliseconds (0-5000), always required for players + - `supported_commands?`: string[] - subset of: 'set_static_delay' + +**Static delay:** The default is 0, meaning audio exits the device's audio port at the timestamp. `static_delay_ms` compensates for additional delay beyond the port (external speakers, amplifiers). Negative values are not supported and should never be required for any compliant implementation. Clients must persist `static_delay_ms` locally across reboots and server reconnections. Clients may update `static_delay_ms` and `supported_commands` when audio output changes (e.g., external speaker connected), persisting separate delays per output. + +### Client → Server: `stream/request-format` player object + +The `player` object in [`stream/request-format`](#client--server-streamrequest-format) has this structure: + +- `player`: object + - `codec?`: 'opus' | 'flac' | 'pcm' - requested codec identifier + - `channels?`: integer - requested number of channels (e.g., 1 = mono, 2 = stereo) + - `sample_rate?`: integer - requested sample rate in Hz (e.g., 44100, 48000) + - `bit_depth?`: integer - requested bit depth (e.g., 16, 24) + +Response: [`stream/start`](#server--client-streamstart) with the new format. + +**Note:** Clients should use this message to adapt to changing network conditions or CPU constraints. The server maintains separate encoding for each client, allowing heterogeneous device capabilities within the same group. + +### Server → Client: `server/command` player object + +The `player` object in [`server/command`](#server--client-servercommand) has this structure: + +Request the player to perform an action, e.g., change volume or mute state. + +- `player`: object + - `command`: 'volume' | 'mute' | 'set_static_delay' - must be listed in `supported_commands` from [`player@v1_support`](#client--server-clienthello-playerv1-support-object) or from [`client/state`](#client--server-clientstate-player-object); unlisted commands are ignored by the client + - `volume?`: integer - volume range 0-100, only set if `command` is `volume` + - `mute?`: boolean - true to mute, false to unmute, only set if `command` is `mute` + - `static_delay_ms?`: integer - delay in milliseconds (0-5000), only set if `command` is `set_static_delay` + +### Server → Client: `stream/start` player object + +The `player` object in [`stream/start`](#server--client-streamstart) has this structure: + +- `player`: object + - `codec`: string - codec to be used + - `sample_rate`: integer - sample rate to be used + - `channels`: integer - channels to be used + - `bit_depth`: integer - bit depth to be used + - `codec_header?`: string - Base64 encoded codec header (if necessary; e.g., FLAC) + +### Server → Client: `stream/clear` player + +When [`stream/clear`](#server--client-streamclear) includes the player role, clients should clear all buffered audio chunks and continue with chunks received after this message. + +### Server → Client: Audio Chunks (Binary) + +Binary messages should be rejected if there is no active stream. + +- Byte 0: message type `4` (uint8) +- Bytes 1-8: timestamp (big-endian int64) - server clock time in microseconds when the first sample should be output +- Rest of bytes: encoded audio frame + +The timestamp indicates when the first audio sample in this chunk should be output. Clients must translate this server timestamp to their local clock using the offset computed from clock synchronization, subtracting their [`static_delay_ms`](#client--server-clientstate-player-object) from the timestamp. Clients should compensate for any known processing delays (e.g., DAC latency, audio buffer delays, amplifier delays) by accounting for these delays when submitting audio to the hardware. + +## Controller messages +This section describes messages specific to clients with the `controller` role, which enables the client to control the Sendspin group this client is part of, and switch between groups. + +Every client which lists the `controller` role in the `supported_roles` of the `client/hello` message needs to implement all messages in this section. + +### Client → Server: `client/command` controller object + +The `controller` object in [`client/command`](#client--server-clientcommand) has this structure: + +Control the group that's playing and switch groups. Only valid from clients with the `controller` role. + +- `controller`: object + - `command`: 'play' | 'pause' | 'stop' | 'next' | 'previous' | 'volume' | 'mute' | 'repeat_off' | 'repeat_one' | 'repeat_all' | 'shuffle' | 'unshuffle' | 'switch' - should be one of the values listed in `supported_commands` from the [`server/state`](#server--client-serverstate-controller-object) `controller` object. Commands not in `supported_commands` are ignored by the server + - `volume?`: integer - volume range 0-100, only set if `command` is `volume` + - `mute?`: boolean - true to mute, false to unmute, only set if `command` is `mute` + +#### Command behaviour + +- 'play' - resume playback from current position. If nothing is currently playing, the server must try to resume the group's last playing media. This history should persist across server and client reboots +- 'pause' - pause playback at current position +- 'stop' - stop playback and reset position to beginning +- 'next' - skip to next track, chapter, etc. +- 'previous' - skip to previous track, chapter, restart current, etc. +- 'volume' - set group volume (requires `volume` parameter) +- 'mute' - set group mute state (requires `mute` parameter) +- 'repeat_off' - disable repeat mode +- 'repeat_one' - repeat the current track continuously +- 'repeat_all' - repeat all tracks continuously +- 'shuffle' - randomize playback order +- 'unshuffle' - restore original playback order +- 'switch' - move this client to the next group in a predefined cycle as described [below](#switch-command-cycle) + +**Setting group volume:** When setting group volume via the 'volume' command, the server applies the following algorithm to preserve relative volume levels while achieving the requested volume as closely as player boundaries allow: + +1. Calculate the delta: `delta = requested_volume - current_group_volume` (where current group volume is the average of all player volumes) +2. Apply the delta to each player's volume +3. Clamp any player volumes that exceed boundaries (0-100%) +4. If any players were clamped: + - Calculate the lost delta: `sum of (proposed_volume - clamped_volume)` for all clamped players + - Divide the lost delta equally among non-clamped players + - Repeat steps 1-4 until either: + - All delta has been successfully applied, or + - All players are clamped at their volume boundaries + +This ensures that when setting group volume to 100%, all players will reach 100% if possible, and the final group volume matches the requested volume as closely as player boundaries allow. + +**Setting group mute:** When setting group mute via the 'mute' command, the server applies the mute state to all players in the group. + +#### Switch command cycle + +**Previous group priority:** If the client is still in the solo group from its `'external_source'` transition, the `switch` command prioritizes rejoining the previous group. + +For clients **with** the `player` role, the cycle includes: +1. Multi-client groups that are currently playing +2. Single-client groups (other players playing alone) +3. A solo group containing only this client + +For clients **without** the `player` role, the cycle includes: +1. Multi-client groups that are currently playing +2. Single-client groups (other players playing alone) + +### Server → Client: `server/state` controller object + +The `controller` object in [`server/state`](#server--client-serverstate) has this structure: + +- `controller`: object + - `supported_commands`: string[] - subset of: 'play' | 'pause' | 'stop' | 'next' | 'previous' | 'volume' | 'mute' | 'repeat_off' | 'repeat_one' | 'repeat_all' | 'shuffle' | 'unshuffle' | 'switch' + - `volume`: integer - volume of the whole group, range 0-100 + - `muted`: boolean - mute state of the whole group + - `repeat`: 'off' | 'one' | 'all' - repeat mode: 'off' = no repeat, 'one' = repeat current track, 'all' = repeat all tracks (in the queue, playlist, etc.) + - `shuffle`: boolean - shuffle mode enabled/disabled + +**Reading group volume:** Group volume is calculated as the average of all player volumes in the group. + +**Reading group mute:** Group mute is `true` only when all players in the group are muted. If some players are muted and others are not, group mute is `false`. + +## Metadata messages +This section describes messages specific to clients with the `metadata` role, which handle display of track information and playback progress. Metadata clients receive state updates with track details. + +### Server → Client: `server/state` metadata object + +The `metadata` object in [`server/state`](#server--client-serverstate) has this structure: + +- `metadata`: object + - `timestamp`: integer - server clock time in microseconds for when this metadata is valid + - `title?`: string | null - track title + - `artist?`: string | null - primary artist(s) + - `album_artist?`: string | null - album artist(s) + - `album?`: string | null - name of the album or release that this track belongs to + - `artwork_url?`: string | null - URL to artwork image. Useful for clients that want to forward metadata to external systems or for powerful clients that can fetch and process images themselves + - `year?`: integer | null - release year in YYYY format + - `track?`: integer | null - track number on the album (1-indexed), null if unknown or not applicable + - `progress?`: object | null - playback progress information. The server must send this object whenever playback state changes (play, pause, resume, seek, playback speed change) + - `track_progress`: integer - current playback position in milliseconds since start of track + - `track_duration`: integer - total track length in milliseconds, 0 for unlimited/unknown duration (e.g., live radio streams) + - `playback_speed`: integer - playback speed multiplier * 1000 (e.g., 1000 = normal speed, 1500 = 1.5x speed, 500 = 0.5x speed, 0 = paused) + +#### Calculating current track position + +Clients can calculate the current track position at any time using the `timestamp` and `progress` values from the last metadata message that included the `progress` object: + +```python +calculated_progress = metadata.progress.track_progress + (current_time - metadata.timestamp) * metadata.progress.playback_speed / 1000000 + +if metadata.progress.track_duration != 0: + current_track_progress_ms = max(min(calculated_progress, metadata.progress.track_duration), 0) +else: + current_track_progress_ms = max(calculated_progress, 0) +``` + +## Artwork messages +This section describes messages specific to clients with the `artwork` role, which handle display of artwork images. Artwork clients receive images in their preferred format and resolution. + +**Channels:** Artwork clients can support 1-4 independent channels, allowing them to display multiple related images. For example, a device could display album artwork on one channel while simultaneously showing artist photos or background images on other channels. Each channel operates independently with its own format, resolution, and source type (album or artist artwork). + +### Client → Server: `client/hello` artwork@v1 support object + +The `artwork@v1_support` object in [`client/hello`](#client--server-clienthello) has this structure: + +- `artwork@v1_support`: object + - `channels`: object[] - list of supported artwork channels (length 1-4), array index is the channel number + - `source`: 'album' | 'artist' | 'none' - artwork source type + - `format`: 'jpeg' | 'png' | 'bmp' - image format identifier + - `media_width`: integer - max width in pixels + - `media_height`: integer - max height in pixels + +**Note:** The server will scale images to fit within the specified dimensions while preserving aspect ratio. Clients can support 1-4 independent artwork channels depending on their display capabilities. The channel number is determined by array position: `channels[0]` is channel 0 (binary message type 8), `channels[1]` is channel 1 (binary message type 9), etc. + +**None source:** If a channel has `source` set to `none`, the server will not send any artwork data for that channel. This allows clients to disable and enable specific channels on the fly through [`stream/request-format`](#client--server-streamrequest-format-artwork-object) without needing to re-establish the WebSocket connection (useful for dynamic display layouts). + +**Note:** Servers must support all image formats: 'jpeg', 'png', and 'bmp'. + +### Client → Server: `stream/request-format` artwork object + +The `artwork` object in [`stream/request-format`](#client--server-streamrequest-format) has this structure: + +Request the server to change the artwork format for a specific channel. The client can send multiple `stream/request-format` messages to change formats on different channels. + +After receiving this message, the server responds with [`stream/start`](#server--client-streamstart) for the artwork role with the new format, followed by immediate artwork updates through binary messages. + +- `artwork`: object + - `channel`: integer - channel number (0-3) corresponding to the channel index declared in the artwork [`client/hello`](#client--server-clienthello-artworkv1-support-object) + - `source?`: 'album' | 'artist' | 'none' - artwork source type + - `format?`: 'jpeg' | 'png' | 'bmp' - requested image format identifier + - `media_width?`: integer - requested max width in pixels + - `media_height?`: integer - requested max height in pixels + +### Server → Client: `stream/start` artwork object + +The `artwork` object in [`stream/start`](#server--client-streamstart) has this structure: + +- `artwork`: object + - `channels`: object[] - configuration for each active artwork channel, array index is the channel number + - `source`: 'album' | 'artist' | 'none' - artwork source type + - `format`: 'jpeg' | 'png' | 'bmp' - format of the encoded image + - `width`: integer - width in pixels of the encoded image + - `height`: integer - height in pixels of the encoded image + +### Server → Client: Artwork (Binary) + +Binary messages should be rejected if there is no active stream. + +- Byte 0: message type `8`-`11` (uint8) - corresponds to artwork channel 0-3 respectively +- Bytes 1-8: timestamp (big-endian int64) - server clock time in microseconds when the image should be displayed by the device +- Rest of bytes: encoded image + +The message type determines which artwork channel this image is for: +- Type `8`: Channel 0 (Artwork role, slot 0) +- Type `9`: Channel 1 (Artwork role, slot 1) +- Type `10`: Channel 2 (Artwork role, slot 2) +- Type `11`: Channel 3 (Artwork role, slot 3) + +The timestamp indicates when this artwork should be displayed. Clients must translate this server timestamp to their local clock using the offset computed from clock synchronization. + +**Clearing artwork:** To clear the currently displayed artwork on a specific channel, the server sends an empty binary message (only the message type byte and timestamp, with no image data) for that channel. + +## Visualizer messages +This section describes messages specific to clients with the `visualizer` role, which create visual representations of the audio being played. Visualizer clients receive audio analysis data like FFT information that corresponds to the current audio timeline. + +### Client → Server: `client/hello` visualizer@v1 support object + +The `visualizer@v1_support` object in [`client/hello`](#client--server-clienthello) has this structure: + +- `visualizer@v1_support`: object + - Desired FFT details (to be determined) + - `buffer_capacity`: integer - max size in bytes of visualization data messages in the buffer that are yet to be displayed + +### Server → Client: `stream/start` visualizer object + +The `visualizer` object in [`stream/start`](#server--client-streamstart) has this structure: + +- `visualizer`: object + - FFT details (to be determined) + +### Server → Client: `stream/clear` visualizer + +When [`stream/clear`](#server--client-streamclear) includes the visualizer role, clients should clear all buffered visualization data and continue with data received after this message. + +### Server → Client: Visualization Data (Binary) + +Binary messages should be rejected if there is no active stream. + +- Byte 0: message type `16` (uint8) +- Bytes 1-8: timestamp (big-endian int64) - server clock time in microseconds when the visualization should be displayed by the device +- Rest of bytes: visualization data + +The timestamp indicates when this visualization data should be displayed, corresponding to the audio timeline. Clients must translate this server timestamp to their local clock using the offset computed from clock synchronization. + +## Color messages +This section describes messages specific to clients with the `color` role, which receive colors derived from the current audio. Colors may be extracted from album artwork, provided by the music source, or manually programmed by the server. + +### Server → Client: `server/state` color object + +The `color` object in [`server/state`](#server--client-serverstate) has this structure: + +- `color`: object + - `timestamp`: integer - server clock time in microseconds for when these colors are valid + - `background_dark?`: integer[] | null - background color suitable for dark mode as `[R, G, B]` with values 0-255. The server must ensure a minimum WCAG contrast ratio of 4.5:1 with white text and with `on_dark` (if also present). + - `background_light?`: integer[] | null - background color suitable for light mode as `[R, G, B]` with values 0-255. The server must ensure a minimum WCAG contrast ratio of 4.5:1 with black text and with `on_light` (if also present). + - `primary?`: integer[] | null - the dominant color, as `[R, G, B]` with values 0-255. Not adjusted for contrast. + - `accent?`: integer[] | null - a secondary or complementary color, as `[R, G, B]` with values 0-255. Not adjusted for contrast. + - `on_dark?`: integer[] | null - a light color suitable for use on dark backgrounds, as `[R, G, B]` with values 0-255 + - `on_light?`: integer[] | null - a dark color suitable for use on light backgrounds, as `[R, G, B]` with values 0-255 diff --git a/third_party/sendspin-go/.github/workflows/ci.yml b/third_party/sendspin-go/.github/workflows/ci.yml new file mode 100644 index 0000000..169f7c8 --- /dev/null +++ b/third_party/sendspin-go/.github/workflows/ci.yml @@ -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 }} diff --git a/third_party/sendspin-go/.github/workflows/conformance.yml b/third_party/sendspin-go/.github/workflows/conformance.yml new file mode 100644 index 0000000..9ccd4bc --- /dev/null +++ b/third_party/sendspin-go/.github/workflows/conformance.yml @@ -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 diff --git a/third_party/sendspin-go/.github/workflows/release.yml b/third_party/sendspin-go/.github/workflows/release.yml new file mode 100644 index 0000000..e6f8488 --- /dev/null +++ b/third_party/sendspin-go/.github/workflows/release.yml @@ -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 }} diff --git a/third_party/sendspin-go/.gitignore b/third_party/sendspin-go/.gitignore new file mode 100644 index 0000000..55f2a95 --- /dev/null +++ b/third_party/sendspin-go/.gitignore @@ -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/ diff --git a/third_party/sendspin-go/.golangci.yml b/third_party/sendspin-go/.golangci.yml new file mode 100644 index 0000000..0d1acc3 --- /dev/null +++ b/third_party/sendspin-go/.golangci.yml @@ -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 diff --git a/third_party/sendspin-go/.pre-commit-config.yaml b/third_party/sendspin-go/.pre-commit-config.yaml new file mode 100644 index 0000000..4b66343 --- /dev/null +++ b/third_party/sendspin-go/.pre-commit-config.yaml @@ -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 diff --git a/third_party/sendspin-go/CHANGELOG.md b/third_party/sendspin-go/CHANGELOG.md new file mode 100644 index 0000000..ec659f1 --- /dev/null +++ b/third_party/sendspin-go/CHANGELOG.md @@ -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 diff --git a/third_party/sendspin-go/CLAUDE.md b/third_party/sendspin-go/CLAUDE.md new file mode 100644 index 0000000..d494997 --- /dev/null +++ b/third_party/sendspin-go/CLAUDE.md @@ -0,0 +1,103 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +This guidance is aimed at Claude Code but may also be suitable for other AI tooling, such as GitHub Copilot and OpenAI Codex. + +## Project Overview + +`sendspin-go` is the Go implementation of the [Sendspin Protocol](https://github.com/Sendspin/website/blob/main/src/spec.md) for synchronized multi-room audio streaming. It ships as a library (`pkg/`) plus two CLI binaries: `sendspin-player` (root `main.go`) and `sendspin-server` (`./cmd/sendspin-server`). The Python sibling is [`aiosendspin`](https://github.com/Sendspin/aiosendspin); the two implementations are wire-compatible and share the role-family vocabulary (`player`, `controller`, `metadata`, `artwork`, `visualizer`). + +**Note**: If uncertain about how something in Sendspin is supposed to work, fetch and refer to the [protocol specification](https://github.com/Sendspin/website/blob/main/src/spec.md) for authoritative implementation details. + +## Commands + +Native deps: `libopus` only. FLAC is pure-Go (`mewkiz/flac`); the Makefile, CI, and release pipelines all build with `GOFLAGS=-tags=nolibopusfile` so `gopkg.in/hraban/opus.v2`'s `opus.Stream` parts (the only consumer of `libopusfile`) are skipped and the binary doesn't link `libopusfile` at runtime. Use `./install-deps.sh` (handles brew/apt/dnf/pacman) or the per-OS commands in README.md. `ffmpeg` is only required for HLS/m3u8 server input. If you ever need to build with the opus.Stream API, override the tag: `make BUILDTAGS= test`. + +```bash +make # Build sendspin-player + sendspin-server +make player # Build sendspin-player only +make server # Build sendspin-server only +make test # go test ./... +make test-coverage # -race + HTML coverage report +make lint # golangci-lint --timeout=5m +make conformance # Run protocol conformance suite (clones ../conformance on first run; needs uv) +go test ./pkg/sendspin -run TestPlayer_Connect -race -v # Single test +``` + +Pre-commit (`.pre-commit-config.yaml`) runs gofmt, goimports, go-mod-tidy, golangci-lint, and `go test -race -v ./...` on every commit. Run pre-commit before pushing any commit to ensure it is valid. + +## Architecture + +Library-first. The public API in `pkg/` is what consumers import; the CLI binaries are thin wrappers. `internal/` is private to the module by Go's rules — only `pkg/sendspin` legitimately reaches into it. + +### Public layers (`pkg/`) + +- **`pkg/sendspin`** (`receiver.go`, `player.go`, `server.go`): High-level API. The library splits the player into a `Receiver` (connect, handshake, clock sync, decode, schedule — emits `<-chan audio.Buffer`) and a `Player` (a thin wrapper composing `Receiver` + `pkg/audio/output.Output`). Use `Receiver` directly for visualizers, DSP, or custom output backends — no audio device required. Each `Receiver`/`Player` owns its own `*sync.ClockSync`; the package-level `SetGlobalClockSync` / `ServerMicrosNow` shims are deprecated. + +- **`pkg/audio`** (`types.go`, `resample.go`, plus `decode/`, `encode/`, `output/` subpackages): Core `Format` and `Buffer` types, sample conversion, linear resampler, codec encoders/decoders (PCM, Opus, FLAC). `output` ships a single `malgo` backend; the `oto` backend was removed in v1.2. + +- **`pkg/protocol`** (`messages.go`, `client.go`, `server_conn.go`): Wire messages plus `Client` (player side) and `ServerConn` (CGO-free helpers for serving binary frames). + +- **`pkg/sync`** (`clock.go`, `timefilter.go`): `ClockSync` plus `TimeFilter`, the 2D Kalman filter tracking offset and drift per the Sendspin time-filter spec. This is what makes hi-res multi-room sync actually work; do not regress it without re-running `make conformance`. + +- **`pkg/discovery`** (`mdns.go`): mDNS browse/advertise. + +### Server-side group / role model (`pkg/sendspin`) + +Two-level architecture, mirroring `aiosendspin` so the wire behavior matches across implementations. + +- **`Group`** (`group.go`): Owns the typed event bus for one playback group. Publishes `ClientJoinedEvent`, `ClientLeftEvent`, `ClientStateChangedEvent`, `GroupStateChangedEvent`, etc. `ClientJoinedEvent` carries a live `*ServerClient` (the connection is fully alive at publish); `ClientLeftEvent` intentionally drops the pointer because the client is mid-teardown. + +- **`GroupRole`** (`group_role.go`): One implementation per role family, coordinating across all member roles in the group. Built-in implementations: `ControllerGroupRole` (`role_controller.go`), `MetadataGroupRole` (`role_metadata.go`), `PlayerGroupRole` (`role_player.go`). Add new server-side behavior by writing a new `GroupRole` and registering it via `activateRoles`, **not** by editing `server_dispatch.go` directly. + +- **`ServerClient`** (`server_client.go`): Per-connection state with typed accessors (`State()`, `Volume()`, `Muted()`, `Codec()`). Message dispatch lives in `server_dispatch.go`, audio streaming in `server_stream.go`, and per-client send-ahead pacing in `buffer_tracker.go`. + +### Audio Pipeline + +``` +Server: AudioSource → codec negotiation per client (Opus/FLAC/PCM) + → 20 ms chunks tagged monotonic-µs server timestamps + → sent ~500 ms ahead → WebSocket binary frames +Player: protocol.Client → pkg/sync (Kalman-mapped local time) + → Scheduler priority queue (200 ms startup buffer) + → pkg/audio/output.Malgo +``` + +Binary message-type IDs encode role bits 7–2 / slot bits 1–0 per spec. Binary messages use a 9-byte header (1B message type + 8B `timestamp_us`); audio chunk = type 4. Artwork has its own slot via `protocol.StreamStart.Artwork`. + +### Configuration & Daemon Mode + +`pkg/sendspin/config.go` provides `PlayerConfigFile` / `ServerConfigFile` plus `LoadPlayerConfig` / `LoadServerConfig` and `ApplyEnvAndFile`. Precedence is **CLI > env (`SENDSPIN_PLAYER_*` / `SENDSPIN_SERVER_*`) > YAML file > built-in default**. Default search paths: `$SENDSPIN_*_CONFIG`, `~/.config/sendspin/*.yaml`, `/etc/sendspin/*.yaml`. Both binaries accept `--config` and `--daemon` (the latter logs to stdout for journalctl). Annotated examples and systemd units live in `dist/config/` and `dist/systemd/`; `make install-{player,server}-daemon` installs them. + +### Internal layout (`internal/`) + +- **`internal/server`**: Audio engine, source decoders, Opus/FLAC encoders, resampler, server TUI. `pkg/sendspin.Server` is a façade over this. +- **`internal/ui`**: bubbletea models, hotkey/device-picker widgets shared by both binaries. +- **`internal/discovery`**: mDNS plumbing (TXT parsing, browse loops) used by `pkg/discovery`. +- **`internal/version`**: ldflags version target. + +The legacy `internal/{app,artwork,audio,client,player,protocol,sync}` packages were deleted in the v1.2 "rip-legacy-cli" sweep. Do not reintroduce that layering. + +## Code Style + +- Go ≥1.24. Module path is `github.com/Sendspin/sendspin-go`. +- Every `.go` file starts with two `// ABOUTME:` header lines summarizing its purpose. Match this on new files. +- Linting: golangci-lint with gosimple, govet, ineffassign, unused, gofmt, goimports, misspell, errcheck enabled; staticcheck intentionally disabled. +- Conventional commits: `type(scope): subject` (`feat`, `fix`, `refactor`, `test`, `chore`, `docs`, `style`, `build`, `ci`). + +## Testing + +- Tests are co-located (`_test.go` next to source). +- Integration tests use the `_integration_test.go` suffix (e.g. `flac_integration_test.go`, `client_discovery_integration_test.go`). +- Wire-format / message-type / negotiation changes must keep `make conformance` green. The harness lives in `Sendspin/conformance` and is symlinked into this checkout on first run. +- Audio invariants — 20 ms chunks (50/s), microsecond timestamps, 9-byte binary header — are interop-critical. Don't change without running the conformance suite against `aiosendspin`. + +## Contribution & AI Policy + +This project follows the [Open Home Foundation AI Policy](https://github.com/music-assistant/.github/blob/main/AI_POLICY.md): + +- **No autonomous agents.** PRs from autonomous agents will be closed. +- **Human-in-the-loop required.** All contributions must be reviewed and understood by the contributor before submission. +- **Disclose AI-generated text.** Quote it with `>` blocks and accompany it with your own commentary explaining relevance and implications. + +PRs target `main`. Recent design notes worth consulting before touching the relevant area: `docs/2026-04-12-layered-architecture-design.md`, `docs/CLOCK_SYNC_ANALYSIS.md`, `docs/FORMAT_NEGOTIATION_FIX.md`, `docs/superpowers/plans/`. diff --git a/third_party/sendspin-go/LICENSE b/third_party/sendspin-go/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/third_party/sendspin-go/LICENSE @@ -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. diff --git a/third_party/sendspin-go/Makefile b/third_party/sendspin-go/Makefile new file mode 100644 index 0000000..1d7a94c --- /dev/null +++ b/third_party/sendspin-go/Makefile @@ -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)" diff --git a/third_party/sendspin-go/README.md b/third_party/sendspin-go/README.md new file mode 100644 index 0000000..953382e --- /dev/null +++ b/third_party/sendspin-go/README.md @@ -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 ` 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_` 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 ` 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_` (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 "" 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) diff --git a/third_party/sendspin-go/cmd/sendspin-server/main.go b/third_party/sendspin-go/cmd/sendspin-server/main.go new file mode 100644 index 0000000..7d68843 --- /dev/null +++ b/third_party/sendspin-go/cmd/sendspin-server/main.go @@ -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, + }) +} diff --git a/third_party/sendspin-go/docs/2026-04-12-layered-architecture-design.md b/third_party/sendspin-go/docs/2026-04-12-layered-architecture-design.md new file mode 100644 index 0000000..2dd95a6 --- /dev/null +++ b/third_party/sendspin-go/docs/2026-04-12-layered-architecture-design.md @@ -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. diff --git a/third_party/sendspin-go/docs/CLOCK_SYNC_ANALYSIS.md b/third_party/sendspin-go/docs/CLOCK_SYNC_ANALYSIS.md new file mode 100644 index 0000000..ab692cc --- /dev/null +++ b/third_party/sendspin-go/docs/CLOCK_SYNC_ANALYSIS.md @@ -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. diff --git a/third_party/sendspin-go/docs/CODE_REVIEW_FIXES.md b/third_party/sendspin-go/docs/CODE_REVIEW_FIXES.md new file mode 100644 index 0000000..82687d3 --- /dev/null +++ b/third_party/sendspin-go/docs/CODE_REVIEW_FIXES.md @@ -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. diff --git a/third_party/sendspin-go/docs/FORMAT_NEGOTIATION_FIX.md b/third_party/sendspin-go/docs/FORMAT_NEGOTIATION_FIX.md new file mode 100644 index 0000000..f4bf21b --- /dev/null +++ b/third_party/sendspin-go/docs/FORMAT_NEGOTIATION_FIX.md @@ -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) diff --git a/third_party/sendspin-go/docs/HIRES_TEST_RESULTS.md b/third_party/sendspin-go/docs/HIRES_TEST_RESULTS.md new file mode 100644 index 0000000..e1adf36 --- /dev/null +++ b/third_party/sendspin-go/docs/HIRES_TEST_RESULTS.md @@ -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**. diff --git a/third_party/sendspin-go/docs/MA_HIRES_ISSUE.md b/third_party/sendspin-go/docs/MA_HIRES_ISSUE.md new file mode 100644 index 0000000..ea87637 --- /dev/null +++ b/third_party/sendspin-go/docs/MA_HIRES_ISSUE.md @@ -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 :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. diff --git a/third_party/sendspin-go/docs/NATIVE_RATE_FIX.md b/third_party/sendspin-go/docs/NATIVE_RATE_FIX.md new file mode 100644 index 0000000..9ed5fce --- /dev/null +++ b/third_party/sendspin-go/docs/NATIVE_RATE_FIX.md @@ -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) diff --git a/third_party/sendspin-go/docs/PHASE1_IMPLEMENTATION.md b/third_party/sendspin-go/docs/PHASE1_IMPLEMENTATION.md new file mode 100644 index 0000000..756493c --- /dev/null +++ b/third_party/sendspin-go/docs/PHASE1_IMPLEMENTATION.md @@ -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 +``` + +--- + +## 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) diff --git a/third_party/sendspin-go/docs/hires-audio-verification.md b/third_party/sendspin-go/docs/hires-audio-verification.md new file mode 100644 index 0000000..53c8232 --- /dev/null +++ b/third_party/sendspin-go/docs/hires-audio-verification.md @@ -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** 🧪 diff --git a/third_party/sendspin-go/docs/plans/2025-10-23-resonate-player-design.md b/third_party/sendspin-go/docs/plans/2025-10-23-resonate-player-design.md new file mode 100644 index 0000000..16ff61a --- /dev/null +++ b/third_party/sendspin-go/docs/plans/2025-10-23-resonate-player-design.md @@ -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/ diff --git a/third_party/sendspin-go/docs/plans/2025-10-23-resonate-player-implementation.md b/third_party/sendspin-go/docs/plans/2025-10-23-resonate-player-implementation.md new file mode 100644 index 0000000..8640484 --- /dev/null +++ b/third_party/sendspin-go/docs/plans/2025-10-23-resonate-player-implementation.md @@ -0,0 +1,2939 @@ +# Resonate Player Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build a Resonate Protocol player in Go that discovers servers via mDNS, receives multi-codec audio streams, maintains precise clock synchronization, and provides an interactive TUI for control and monitoring. + +**Architecture:** Event-driven design with dedicated goroutines for discovery, WebSocket communication, clock sync, audio decoding (Opus/FLAC/PCM), timestamp-based playback scheduling, and TUI. Components communicate via typed Go channels. + +**Tech Stack:** Go 1.21+, gorilla/websocket, hashicorp/mdns, ebitengine/oto, hraban/opus, mewkiz/flac, charmbracelet/bubbletea + +--- + +## Task 1: Project Initialization and Basic Structure + +**Files:** +- Create: `go.mod` +- Create: `main.go` +- Create: `internal/version/version.go` +- Create: `.gitignore` +- Create: `README.md` + +**Step 1: Initialize Go module** + +Run: +```bash +uv init --name resonate-player +``` + +Wait, this is a Go project. Run: +```bash +go mod init github.com/Resonate-Protocol/resonate-go +``` + +Expected: Creates `go.mod` with module declaration + +**Step 2: Create basic main.go structure** + +Create `main.go`: +```go +// ABOUTME: Entry point for Resonate Protocol player +// ABOUTME: Parses CLI flags and starts the player application +package main + +import ( + "flag" + "fmt" + "log" + "os" +) + +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-resonate-player)") + bufferMs = flag.Int("buffer-ms", 150, "Jitter buffer size in milliseconds") + logFile = flag.String("log-file", "resonate-player.log", "Log file path") + debug = flag.Bool("debug", false, "Enable debug logging") +) + +func main() { + flag.Parse() + + // Set up logging + f, err := os.OpenFile(*logFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) + if err != nil { + log.Fatalf("error opening log file: %v", err) + } + defer f.Close() + log.SetOutput(f) + + // Determine player name + playerName := *name + if playerName == "" { + hostname, err := os.Hostname() + if err != nil { + hostname = "unknown" + } + playerName = fmt.Sprintf("%s-resonate-player", hostname) + } + + log.Printf("Starting Resonate Player: %s", playerName) + fmt.Printf("Resonate Player starting...\n") + fmt.Printf("Name: %s\n", playerName) + fmt.Printf("Port: %d\n", *port) + fmt.Printf("Buffer: %dms\n", *bufferMs) + + // TODO: Start player +} +``` + +**Step 3: Create version package** + +Create `internal/version/version.go`: +```go +// ABOUTME: Version information for the player +// ABOUTME: Used in device_info sent during handshake +package version + +const ( + Version = "0.1.0" + Product = "Resonate Go Player" + Manufacturer = "resonate-go" +) +``` + +**Step 4: Create .gitignore** + +Create `.gitignore`: +``` +# Binaries +resonate-player +*.exe +*.dll +*.so +*.dylib + +# Test binaries +*.test + +# Coverage +*.out + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Logs +*.log + +# OS +.DS_Store +Thumbs.db +``` + +**Step 5: Create README** + +Create `README.md`: +```markdown +# Resonate Go Player + +A Resonate Protocol player implementation in Go. + +## Features + +- mDNS service discovery (client and server initiated) +- Multi-codec support (Opus, FLAC, PCM) +- Precise clock synchronization for multi-room audio +- Interactive terminal UI +- Software volume control + +## Installation + +```bash +go build -o resonate-player +``` + +## Usage + +```bash +./resonate-player --name "Living Room" +``` + +## Options + +- `--server` - Manual server address (skip mDNS) +- `--port` - Port for mDNS advertisement (default: 8927) +- `--name` - Player friendly name +- `--buffer-ms` - Jitter buffer size (default: 150ms) +- `--log-file` - Log file path +- `--debug` - Enable debug logging + +## Protocol + +Implements the [Resonate Protocol](https://github.com/Resonate-Protocol/spec). +``` + +**Step 6: Test build** + +Run: +```bash +go build -o resonate-player +``` + +Expected: Builds successfully, creates `resonate-player` binary + +**Step 7: Test run** + +Run: +```bash +./resonate-player --help +``` + +Expected: Shows usage information with all flags + +**Step 8: Commit** + +```bash +git add go.mod main.go internal/version/version.go .gitignore README.md +git commit -m "feat: initialize project structure + +- Set up Go module +- Create main entry point with CLI flags +- Add version package +- Add README and .gitignore + +🤖 Generated with [Claude Code](https://claude.com/claude-code) + +Co-Authored-By: Claude " +``` + +--- + +## Task 2: Protocol Message Types + +**Files:** +- Create: `internal/protocol/messages.go` +- Create: `internal/protocol/messages_test.go` + +**Step 1: Write test for message marshaling** + +Create `internal/protocol/messages_test.go`: +```go +// ABOUTME: Tests for Resonate Protocol message types +// ABOUTME: Verifies JSON marshaling/unmarshaling of protocol messages +package protocol + +import ( + "encoding/json" + "testing" +) + +func TestClientHelloMarshaling(t *testing.T) { + hello := ClientHello{ + ClientID: "test-id", + Name: "Test Player", + Version: 1, + SupportedRoles: []string{"player"}, + DeviceInfo: &DeviceInfo{ + ProductName: "Test Product", + Manufacturer: "Test Mfg", + SoftwareVersion: "0.1.0", + }, + PlayerSupport: &PlayerSupport{ + Codecs: []string{"opus", "flac", "pcm"}, + SampleRates: []int{44100, 48000}, + Channels: []int{1, 2}, + BitDepths: []int{16, 24}, + }, + } + + msg := Message{ + Type: "client/hello", + Payload: hello, + } + + data, err := json.Marshal(msg) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + + var decoded Message + err = json.Unmarshal(data, &decoded) + if err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if decoded.Type != "client/hello" { + t.Errorf("expected type client/hello, got %s", decoded.Type) + } +} + +func TestClientStateMarshaling(t *testing.T) { + state := ClientState{ + State: "synchronized", + Volume: 80, + Muted: false, + } + + msg := Message{ + Type: "client/state", + Payload: state, + } + + data, err := json.Marshal(msg) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + + var decoded Message + err = json.Unmarshal(data, &decoded) + if err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if decoded.Type != "client/state" { + t.Errorf("expected type client/state, got %s", decoded.Type) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: +```bash +go test ./internal/protocol/... -v +``` + +Expected: FAIL with "no such file or directory" or package not found + +**Step 3: Create message types** + +Create `internal/protocol/messages.go`: +```go +// ABOUTME: Resonate Protocol message type definitions +// ABOUTME: Defines structs for all message types in the protocol +package protocol + +// Message is the top-level wrapper for all protocol messages +type Message struct { + Type string `json:"type"` + Payload interface{} `json:"payload"` +} + +// ClientHello is sent by clients to initiate the handshake +type ClientHello struct { + ClientID string `json:"client_id"` + Name string `json:"name"` + Version int `json:"version"` + SupportedRoles []string `json:"supported_roles"` + DeviceInfo *DeviceInfo `json:"device_info,omitempty"` + PlayerSupport *PlayerSupport `json:"player_support,omitempty"` +} + +// DeviceInfo contains device identification +type DeviceInfo struct { + ProductName string `json:"product_name"` + Manufacturer string `json:"manufacturer"` + SoftwareVersion string `json:"software_version"` +} + +// PlayerSupport describes player capabilities +type PlayerSupport struct { + Codecs []string `json:"codecs"` + SampleRates []int `json:"sample_rates"` + Channels []int `json:"channels"` + BitDepths []int `json:"bit_depths"` +} + +// ServerHello is the server's response to client/hello +type ServerHello struct { + ServerID string `json:"server_id"` + Name string `json:"name"` + Version int `json:"version"` +} + +// ClientState reports the player's current state +type ClientState struct { + State string `json:"state,omitempty"` + Volume int `json:"volume,omitempty"` + Muted bool `json:"muted,omitempty"` +} + +// ServerCommand is a control message from the server +type ServerCommand struct { + Command string `json:"command"` + Volume int `json:"volume,omitempty"` + Mute bool `json:"mute,omitempty"` +} + +// StreamStart notifies the client of stream format +type StreamStart struct { + Codec string `json:"codec"` + SampleRate int `json:"sample_rate"` + Channels int `json:"channels"` + BitDepth int `json:"bit_depth"` + CodecHeader string `json:"codec_header,omitempty"` // Base64-encoded +} + +// StreamMetadata contains track information +type StreamMetadata struct { + Title string `json:"title,omitempty"` + Artist string `json:"artist,omitempty"` + Album string `json:"album,omitempty"` + ArtworkURL string `json:"artwork_url,omitempty"` +} + +// ClientTime is sent for clock synchronization +type ClientTime struct { + T1 int64 `json:"t1"` // Client timestamp in microseconds +} + +// ServerTime is the response to client/time +type ServerTime struct { + T1 int64 `json:"t1"` // Echoed client timestamp + T2 int64 `json:"t2"` // Server receive timestamp + T3 int64 `json:"t3"` // Server send timestamp +} +``` + +**Step 4: Run tests to verify they pass** + +Run: +```bash +go test ./internal/protocol/... -v +``` + +Expected: PASS (2 tests) + +**Step 5: Commit** + +```bash +git add internal/protocol/ +git commit -m "feat: add protocol message types + +- Define all Resonate Protocol message structs +- Add JSON marshaling tests +- Support client/server handshake messages +- Support state, command, stream, and time sync messages + +🤖 Generated with [Claude Code](https://claude.com/claude-code) + +Co-Authored-By: Claude " +``` + +--- + +## Task 3: WebSocket Client with Handshake + +**Files:** +- Create: `internal/client/websocket.go` +- Create: `internal/client/websocket_test.go` + +**Step 1: Write test for WebSocket client creation** + +Create `internal/client/websocket_test.go`: +```go +// ABOUTME: Tests for WebSocket client implementation +// ABOUTME: Tests connection, handshake, and message routing +package client + +import ( + "testing" +) + +func TestNewClient(t *testing.T) { + config := Config{ + ServerAddr: "localhost:8927", + ClientID: "test-client", + Name: "Test Player", + } + + client := NewClient(config) + if client == nil { + t.Fatal("expected client to be created") + } + + if client.config.ServerAddr != "localhost:8927" { + t.Errorf("expected server addr localhost:8927, got %s", client.config.ServerAddr) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: +```bash +go test ./internal/client/... -v +``` + +Expected: FAIL with package not found + +**Step 3: Create WebSocket client structure** + +Create `internal/client/websocket.go`: +```go +// ABOUTME: WebSocket client for Resonate Protocol communication +// ABOUTME: Handles connection, handshake, and message routing +package client + +import ( + "context" + "encoding/binary" + "encoding/json" + "fmt" + "log" + "net/url" + "sync" + "time" + + "github.com/Resonate-Protocol/resonate-go/internal/protocol" + "github.com/gorilla/websocket" +) + +// Config holds client configuration +type Config struct { + ServerAddr string + ClientID string + Name string + Version int + DeviceInfo protocol.DeviceInfo + PlayerSupport protocol.PlayerSupport +} + +// Client represents a WebSocket client +type Client struct { + config Config + conn *websocket.Conn + mu sync.RWMutex + + // Message channels + AudioChunks chan AudioChunk + ControlMsgs chan protocol.ServerCommand + TimeSyncResp chan protocol.ServerTime + StreamStart chan protocol.StreamStart + Metadata chan protocol.StreamMetadata + + // State + connected bool + ctx context.Context + cancel context.CancelFunc +} + +// AudioChunk represents a timestamped audio frame +type AudioChunk struct { + Timestamp int64 // Microseconds, server clock + Data []byte // Encoded audio +} + +// NewClient creates a new WebSocket client +func NewClient(config Config) *Client { + ctx, cancel := context.WithCancel(context.Background()) + + return &Client{ + config: config, + AudioChunks: make(chan AudioChunk, 100), + ControlMsgs: make(chan protocol.ServerCommand, 10), + TimeSyncResp: make(chan protocol.ServerTime, 10), + StreamStart: make(chan protocol.StreamStart, 1), + Metadata: make(chan protocol.StreamMetadata, 10), + ctx: ctx, + cancel: cancel, + } +} + +// Connect establishes WebSocket connection and performs handshake +func (c *Client) Connect() error { + u := url.URL{Scheme: "ws", Host: c.config.ServerAddr, Path: "/resonate"} + log.Printf("Connecting to %s", u.String()) + + conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil) + if err != nil { + return fmt.Errorf("dial failed: %w", err) + } + + c.mu.Lock() + c.conn = conn + c.connected = true + c.mu.Unlock() + + // Perform handshake + if err := c.handshake(); err != nil { + c.Close() + return fmt.Errorf("handshake failed: %w", err) + } + + // Start message reader + go c.readMessages() + + return nil +} + +// handshake performs the protocol handshake +func (c *Client) handshake() error { + // Send client/hello + hello := protocol.ClientHello{ + ClientID: c.config.ClientID, + Name: c.config.Name, + Version: c.config.Version, + SupportedRoles: []string{"player"}, + DeviceInfo: &c.config.DeviceInfo, + PlayerSupport: &c.config.PlayerSupport, + } + + msg := protocol.Message{ + Type: "client/hello", + Payload: hello, + } + + if err := c.sendJSON(msg); err != nil { + return fmt.Errorf("failed to send client/hello: %w", err) + } + + // Wait for server/hello (with timeout) + c.conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + _, data, err := c.conn.ReadMessage() + if err != nil { + return fmt.Errorf("failed to read server/hello: %w", err) + } + c.conn.SetReadDeadline(time.Time{}) // Clear deadline + + var serverMsg protocol.Message + if err := json.Unmarshal(data, &serverMsg); err != nil { + return fmt.Errorf("failed to parse server/hello: %w", err) + } + + if serverMsg.Type != "server/hello" { + return fmt.Errorf("expected server/hello, got %s", serverMsg.Type) + } + + log.Printf("Handshake complete with server") + + // Send initial state + state := protocol.ClientState{ + State: "synchronized", + Volume: 100, + Muted: false, + } + + stateMsg := protocol.Message{ + Type: "client/state", + Payload: state, + } + + if err := c.sendJSON(stateMsg); err != nil { + return fmt.Errorf("failed to send initial state: %w", err) + } + + return nil +} + +// sendJSON sends a JSON message +func (c *Client) sendJSON(msg protocol.Message) error { + c.mu.RLock() + defer c.mu.RUnlock() + + if !c.connected { + return fmt.Errorf("not connected") + } + + return c.conn.WriteJSON(msg) +} + +// readMessages reads and routes incoming messages +func (c *Client) readMessages() { + defer c.Close() + + for { + select { + case <-c.ctx.Done(): + return + default: + } + + messageType, data, err := c.conn.ReadMessage() + if err != nil { + log.Printf("Read error: %v", err) + return + } + + if messageType == websocket.BinaryMessage { + c.handleBinaryMessage(data) + } else if messageType == websocket.TextMessage { + c.handleJSONMessage(data) + } + } +} + +// handleBinaryMessage handles audio chunks +func (c *Client) handleBinaryMessage(data []byte) { + if len(data) < 9 { + log.Printf("Invalid binary message: too short") + return + } + + msgType := data[0] + if msgType != 0 { + log.Printf("Unknown binary message type: %d", msgType) + return + } + + timestamp := int64(binary.BigEndian.Uint64(data[1:9])) + audioData := data[9:] + + chunk := AudioChunk{ + Timestamp: timestamp, + Data: audioData, + } + + select { + case c.AudioChunks <- chunk: + case <-c.ctx.Done(): + } +} + +// handleJSONMessage routes JSON messages +func (c *Client) handleJSONMessage(data []byte) { + var msg protocol.Message + if err := json.Unmarshal(data, &msg); err != nil { + log.Printf("Failed to parse JSON message: %v", err) + return + } + + payloadBytes, _ := json.Marshal(msg.Payload) + + switch msg.Type { + case "server/command": + var cmd protocol.ServerCommand + json.Unmarshal(payloadBytes, &cmd) + select { + case c.ControlMsgs <- cmd: + case <-c.ctx.Done(): + } + + case "server/time": + var timeMsg protocol.ServerTime + json.Unmarshal(payloadBytes, &timeMsg) + select { + case c.TimeSyncResp <- timeMsg: + case <-c.ctx.Done(): + } + + case "stream/start": + var start protocol.StreamStart + json.Unmarshal(payloadBytes, &start) + select { + case c.StreamStart <- start: + case <-c.ctx.Done(): + } + + case "stream/metadata": + var meta protocol.StreamMetadata + json.Unmarshal(payloadBytes, &meta) + select { + case c.Metadata <- meta: + case <-c.ctx.Done(): + } + + default: + log.Printf("Unknown message type: %s", msg.Type) + } +} + +// SendState sends a client/state message +func (c *Client) SendState(state protocol.ClientState) error { + msg := protocol.Message{ + Type: "client/state", + Payload: state, + } + return c.sendJSON(msg) +} + +// SendTimeSync sends a client/time message +func (c *Client) SendTimeSync(t1 int64) error { + msg := protocol.Message{ + Type: "client/time", + Payload: protocol.ClientTime{ + T1: t1, + }, + } + return c.sendJSON(msg) +} + +// Close closes the connection +func (c *Client) Close() { + c.mu.Lock() + defer c.mu.Unlock() + + if c.connected { + c.connected = false + c.cancel() + c.conn.Close() + log.Printf("Connection closed") + } +} + +// IsConnected returns connection status +func (c *Client) IsConnected() bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.connected +} +``` + +**Step 4: Install dependencies** + +Run: +```bash +go get github.com/gorilla/websocket +go mod tidy +``` + +Expected: Dependencies downloaded and go.mod updated + +**Step 5: Run tests to verify they pass** + +Run: +```bash +go test ./internal/client/... -v +``` + +Expected: PASS (1 test) + +**Step 6: Commit** + +```bash +git add internal/client/ go.mod go.sum +git commit -m "feat: implement WebSocket client with handshake + +- Create WebSocket client with connection management +- Implement Resonate Protocol handshake (client/hello, server/hello) +- Add message routing for audio, control, time sync +- Parse binary audio chunks with timestamps +- Add channels for inter-component communication + +🤖 Generated with [Claude Code](https://claude.com/claude-code) + +Co-Authored-By: Claude " +``` + +--- + +## Task 4: Clock Synchronization + +**Files:** +- Create: `internal/sync/clock.go` +- Create: `internal/sync/clock_test.go` + +**Step 1: Write test for offset calculation** + +Create `internal/sync/clock_test.go`: +```go +// ABOUTME: Tests for clock synchronization implementation +// ABOUTME: Tests offset calculation and exponential smoothing +package sync + +import ( + "math" + "testing" +) + +func TestOffsetCalculation(t *testing.T) { + // Simulate a sync exchange + t1 := int64(1000000) // Client send + t2 := int64(1002000) // Server receive (+2ms) + t3 := int64(1002500) // Server respond (+0.5ms processing) + t4 := int64(1005000) // Client receive (+2.5ms return) + + rtt, offset := calculateOffset(t1, t2, t3, t4) + + // RTT = (t4-t1) - (t3-t2) = 5000 - 500 = 4500μs + expectedRTT := int64(4500) + if rtt != expectedRTT { + t.Errorf("expected RTT %d, got %d", expectedRTT, rtt) + } + + // Offset = ((t2-t1) + (t3-t4)) / 2 = (2000 + (-2500)) / 2 = -250μs + expectedOffset := int64(-250) + if offset != expectedOffset { + t.Errorf("expected offset %d, got %d", expectedOffset, offset) + } +} + +func TestSmoothing(t *testing.T) { + cs := NewClockSync() + + // First sample + cs.ProcessSyncResponse(1000, 1002, 1003, 1006) + offset1 := cs.GetOffset() + + // Second sample + cs.ProcessSyncResponse(2000, 2002, 2003, 2006) + offset2 := cs.GetOffset() + + // Should be smoothed (not equal to raw second sample) + if offset2 == -250 { + t.Error("expected smoothed offset, got raw value") + } + + // Should be moving toward new value + if math.Abs(float64(offset2-offset1)) < 1 { + t.Error("expected offset to change with new sample") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: +```bash +go test ./internal/sync/... -v +``` + +Expected: FAIL with package not found + +**Step 3: Implement clock synchronization** + +Create `internal/sync/clock.go`: +```go +// ABOUTME: Clock synchronization using NTP-style algorithm +// ABOUTME: Maintains offset between client and server clocks +package sync + +import ( + "log" + "sync" + "time" +) + +// ClockSync manages clock synchronization with the server +type ClockSync struct { + mu sync.RWMutex + offset int64 // Smoothed offset in microseconds + rawOffset int64 // Latest raw offset + rtt int64 // Latest round-trip time + quality Quality + lastSync time.Time + sampleCount int + smoothingRate float64 +} + +// Quality represents sync quality +type Quality int + +const ( + QualityGood Quality = iota + QualityDegraded + QualityLost +) + +// NewClockSync creates a new clock synchronizer +func NewClockSync() *ClockSync { + return &ClockSync{ + smoothingRate: 0.1, // 10% weight to new samples + quality: QualityLost, + } +} + +// ProcessSyncResponse processes a server/time response +func (cs *ClockSync) ProcessSyncResponse(t1, t2, t3, t4 int64) { + rtt, offset := calculateOffset(t1, t2, t3, t4) + + cs.mu.Lock() + defer cs.mu.Unlock() + + cs.rtt = rtt + cs.rawOffset = offset + cs.lastSync = time.Now() + + // Discard samples with high RTT (network congestion) + if rtt > 100000 { // 100ms + log.Printf("Discarding sync sample: high RTT %dμs", rtt) + return + } + + // Apply exponential smoothing + if cs.sampleCount == 0 { + cs.offset = offset + } else { + cs.offset = int64(float64(cs.offset)*(1-cs.smoothingRate) + + float64(offset)*cs.smoothingRate) + } + + cs.sampleCount++ + + // Update quality + if rtt < 50000 { // <50ms + cs.quality = QualityGood + } else { + cs.quality = QualityDegraded + } + + log.Printf("Clock sync: offset=%dμs, rtt=%dμs, quality=%v", + cs.offset, cs.rtt, cs.quality) +} + +// calculateOffset computes RTT and clock offset +func calculateOffset(t1, t2, t3, t4 int64) (rtt, offset int64) { + // Round-trip time + rtt = (t4 - t1) - (t3 - t2) + + // Estimated offset (positive = server ahead) + offset = ((t2 - t1) + (t3 - t4)) / 2 + + return +} + +// GetOffset returns the smoothed clock offset +func (cs *ClockSync) GetOffset() int64 { + cs.mu.RLock() + defer cs.mu.RUnlock() + return cs.offset +} + +// GetStats returns sync statistics +func (cs *ClockSync) GetStats() (offset, rtt int64, quality Quality) { + cs.mu.RLock() + defer cs.mu.RUnlock() + return cs.offset, cs.rtt, cs.quality +} + +// CheckQuality updates quality based on time since last sync +func (cs *ClockSync) CheckQuality() Quality { + cs.mu.Lock() + defer cs.mu.Unlock() + + if time.Since(cs.lastSync) > 5*time.Second { + cs.quality = QualityLost + } + + return cs.quality +} + +// ServerToLocalTime converts server timestamp to local time +func (cs *ClockSync) ServerToLocalTime(serverTime int64) time.Time { + offset := cs.GetOffset() + localMicros := serverTime - offset + return time.Unix(0, localMicros*1000) +} + +// CurrentMicros returns current time in microseconds +func CurrentMicros() int64 { + return time.Now().UnixNano() / 1000 +} +``` + +**Step 4: Run tests to verify they pass** + +Run: +```bash +go test ./internal/sync/... -v +``` + +Expected: PASS (2 tests) + +**Step 5: Commit** + +```bash +git add internal/sync/ +git commit -m "feat: implement clock synchronization + +- NTP-style three-timestamp algorithm +- Exponential smoothing for stability +- RTT-based sample filtering +- Quality tracking (good/degraded/lost) +- Server-to-local timestamp conversion + +🤖 Generated with [Claude Code](https://claude.com/claude-code) + +Co-Authored-By: Claude " +``` + +--- + +## Task 5: Audio Decoder (Multi-Codec) + +**Files:** +- Create: `internal/audio/decoder.go` +- Create: `internal/audio/types.go` +- Create: `internal/audio/decoder_test.go` + +**Step 1: Write test for decoder creation** + +Create `internal/audio/decoder_test.go`: +```go +// ABOUTME: Tests for audio decoder implementation +// ABOUTME: Tests multi-codec decoding (Opus, FLAC, PCM) +package audio + +import ( + "testing" +) + +func TestNewDecoder(t *testing.T) { + format := Format{ + Codec: "pcm", + SampleRate: 48000, + Channels: 2, + BitDepth: 16, + } + + decoder, err := NewDecoder(format) + if err != nil { + t.Fatalf("failed to create decoder: %v", err) + } + + if decoder == nil { + t.Fatal("expected decoder to be created") + } +} + +func TestPCMDecoder(t *testing.T) { + format := Format{ + Codec: "pcm", + SampleRate: 48000, + Channels: 2, + BitDepth: 16, + } + + decoder, err := NewDecoder(format) + if err != nil { + t.Fatalf("failed to create decoder: %v", err) + } + + // PCM is pass-through + input := []byte{0x00, 0x01, 0x02, 0x03} + output, err := decoder.Decode(input) + if err != nil { + t.Fatalf("decode failed: %v", err) + } + + if len(output) != len(input) { + t.Errorf("expected output length %d, got %d", len(input), len(output)) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: +```bash +go test ./internal/audio/... -v +``` + +Expected: FAIL with package not found + +**Step 3: Create audio types** + +Create `internal/audio/types.go`: +```go +// ABOUTME: Audio type definitions +// ABOUTME: Defines audio formats and decoded buffers +package audio + +import "time" + +// 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 []byte // PCM samples + Format Format +} +``` + +**Step 4: Create decoder implementation** + +Create `internal/audio/decoder.go`: +```go +// ABOUTME: Multi-codec audio decoder +// ABOUTME: Supports Opus, FLAC, and PCM formats +package audio + +import ( + "encoding/base64" + "fmt" + "io" + + "github.com/hraban/opus" + "github.com/mewkiz/flac" +) + +// Decoder decodes audio in various formats +type Decoder interface { + Decode(data []byte) ([]byte, error) + Close() error +} + +// NewDecoder creates a decoder for the specified format +func NewDecoder(format Format) (Decoder, error) { + switch format.Codec { + case "pcm": + return &PCMDecoder{}, nil + case "opus": + return NewOpusDecoder(format) + case "flac": + return NewFLACDecoder(format) + default: + return nil, fmt.Errorf("unsupported codec: %s", format.Codec) + } +} + +// PCMDecoder is a pass-through for raw PCM +type PCMDecoder struct{} + +func (d *PCMDecoder) Decode(data []byte) ([]byte, error) { + return data, nil +} + +func (d *PCMDecoder) Close() error { + return nil +} + +// OpusDecoder decodes Opus audio +type OpusDecoder struct { + decoder *opus.Decoder + format Format +} + +func NewOpusDecoder(format Format) (*OpusDecoder, error) { + 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, + }, nil +} + +func (d *OpusDecoder) Decode(data []byte) ([]byte, error) { + // Opus decoder outputs to int16 buffer + pcmSize := 5760 * d.format.Channels // Max frame size + pcm := make([]int16, pcmSize) + + n, err := d.decoder.Decode(data, pcm) + if err != nil { + return nil, fmt.Errorf("opus decode failed: %w", err) + } + + // Convert int16 to bytes + output := make([]byte, n*d.format.Channels*2) + for i := 0; i < n*d.format.Channels; i++ { + output[i*2] = byte(pcm[i]) + output[i*2+1] = byte(pcm[i] >> 8) + } + + return output, nil +} + +func (d *OpusDecoder) Close() error { + return nil +} + +// FLACDecoder decodes FLAC audio +type FLACDecoder struct { + format Format +} + +func NewFLACDecoder(format Format) (*FLACDecoder, error) { + // FLAC decoder will be created per-chunk if needed + // For now, basic support + return &FLACDecoder{ + format: format, + }, nil +} + +func (d *FLACDecoder) Decode(data []byte) ([]byte, error) { + // For streaming FLAC, we need to handle frame-by-frame decoding + // This is a simplified implementation + // In production, would use mewkiz/flac's streaming API + return nil, fmt.Errorf("FLAC streaming not yet implemented") +} + +func (d *FLACDecoder) Close() error { + return nil +} + +// DecodeBase64Header decodes a base64-encoded codec header +func DecodeBase64Header(encoded string) ([]byte, error) { + return base64.StdEncoding.DecodeString(encoded) +} +``` + +**Step 5: Install dependencies** + +Run: +```bash +go get github.com/hraban/opus +go get github.com/mewkiz/flac +go mod tidy +``` + +Expected: Dependencies downloaded + +**Step 6: Run tests to verify they pass** + +Run: +```bash +go test ./internal/audio/... -v +``` + +Expected: PASS (2 tests) + +**Step 7: Commit** + +```bash +git add internal/audio/ go.mod go.sum +git commit -m "feat: implement multi-codec audio decoder + +- Support PCM (pass-through) +- Support Opus decoding via hraban/opus +- Add FLAC decoder structure (streaming TBD) +- Define audio format and buffer types + +🤖 Generated with [Claude Code](https://claude.com/claude-code) + +Co-Authored-By: Claude " +``` + +--- + +## Task 6: Playback Scheduler + +**Files:** +- Create: `internal/player/scheduler.go` +- Create: `internal/player/scheduler_test.go` + +**Step 1: Write test for scheduler timing** + +Create `internal/player/scheduler_test.go`: +```go +// ABOUTME: Tests for playback scheduler +// ABOUTME: Tests timestamp-based scheduling and buffer management +package player + +import ( + "testing" + "time" +) + +func TestSchedulePlayback(t *testing.T) { + now := time.Now() + nowMicros := now.UnixNano() / 1000 + + // Schedule for 100ms in future + playTime := nowMicros + 100000 + localPlayTime := time.Unix(0, playTime*1000) + + sleepDuration := localPlayTime.Sub(now) + + if sleepDuration < 50*time.Millisecond || sleepDuration > 150*time.Millisecond { + t.Errorf("expected sleep ~100ms, got %v", sleepDuration) + } +} + +func TestLateFrameDetection(t *testing.T) { + now := time.Now() + nowMicros := now.UnixNano() / 1000 + + // Frame scheduled 100ms ago + playTime := nowMicros - 100000 + localPlayTime := time.Unix(0, playTime*1000) + + sleepDuration := localPlayTime.Sub(now) + + if sleepDuration >= 0 { + t.Error("expected negative sleep duration for late frame") + } + + // Should drop if >50ms late + shouldDrop := sleepDuration < -50*time.Millisecond + if !shouldDrop { + t.Error("expected to drop frame >50ms late") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: +```bash +go test ./internal/player/... -v +``` + +Expected: FAIL with package not found + +**Step 3: Implement scheduler** + +Create `internal/player/scheduler.go`: +```go +// ABOUTME: Timestamp-based playback scheduler +// ABOUTME: Schedules audio buffers for precise playback timing +package player + +import ( + "container/heap" + "context" + "log" + "time" + + "github.com/Resonate-Protocol/resonate-go/internal/audio" + "github.com/Resonate-Protocol/resonate-go/internal/sync" +) + +// Scheduler manages playback timing +type Scheduler struct { + clockSync *sync.ClockSync + bufferQ *BufferQueue + output chan audio.Buffer + jitterMs int + ctx context.Context + cancel context.CancelFunc + + stats SchedulerStats +} + +// SchedulerStats tracks scheduler metrics +type SchedulerStats struct { + Received int64 + Played int64 + Dropped int64 +} + +// NewScheduler creates a playback scheduler +func NewScheduler(clockSync *sync.ClockSync, jitterMs int) *Scheduler { + ctx, cancel := context.WithCancel(context.Background()) + + return &Scheduler{ + clockSync: clockSync, + bufferQ: NewBufferQueue(), + output: make(chan audio.Buffer, 10), + jitterMs: jitterMs, + ctx: ctx, + cancel: cancel, + } +} + +// Schedule adds a buffer to the queue +func (s *Scheduler) Schedule(buf audio.Buffer) { + // Convert server timestamp to local play time + buf.PlayAt = s.clockSync.ServerToLocalTime(buf.Timestamp) + + s.stats.Received++ + heap.Push(s.bufferQ, buf) +} + +// Run starts the scheduler loop +func (s *Scheduler) Run() { + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-s.ctx.Done(): + return + case <-ticker.C: + s.processQueue() + } + } +} + +// processQueue checks for buffers ready to play +func (s *Scheduler) processQueue() { + now := time.Now() + + for s.bufferQ.Len() > 0 { + buf := s.bufferQ.Peek() + + delay := buf.PlayAt.Sub(now) + + if delay > 50*time.Millisecond { + // Too early, wait + break + } else if delay < -50*time.Millisecond { + // Too late (>50ms), drop + heap.Pop(s.bufferQ) + s.stats.Dropped++ + log.Printf("Dropped late buffer: %v late", -delay) + } else { + // Ready to play (within ±50ms window) + heap.Pop(s.bufferQ) + + select { + case s.output <- buf: + s.stats.Played++ + case <-s.ctx.Done(): + return + } + } + } +} + +// Output returns the output channel +func (s *Scheduler) Output() <-chan audio.Buffer { + return s.output +} + +// Stats returns scheduler statistics +func (s *Scheduler) Stats() SchedulerStats { + return s.stats +} + +// Stop stops the scheduler +func (s *Scheduler) Stop() { + s.cancel() +} + +// BufferQueue is a priority queue for audio buffers +type BufferQueue struct { + items []audio.Buffer +} + +func NewBufferQueue() *BufferQueue { + q := &BufferQueue{} + heap.Init(q) + return q +} + +// Implement heap.Interface +func (q *BufferQueue) Len() int { return len(q.items) } + +func (q *BufferQueue) Less(i, j int) bool { + return q.items[i].PlayAt.Before(q.items[j].PlayAt) +} + +func (q *BufferQueue) Swap(i, j int) { + q.items[i], q.items[j] = q.items[j], q.items[i] +} + +func (q *BufferQueue) Push(x interface{}) { + q.items = append(q.items, x.(audio.Buffer)) +} + +func (q *BufferQueue) Pop() interface{} { + n := len(q.items) + item := q.items[n-1] + q.items = q.items[:n-1] + return item +} + +func (q *BufferQueue) Peek() audio.Buffer { + return q.items[0] +} +``` + +**Step 4: Run tests to verify they pass** + +Run: +```bash +go test ./internal/player/... -v +``` + +Expected: PASS (2 tests) + +**Step 5: Commit** + +```bash +git add internal/player/ +git commit -m "feat: implement playback scheduler + +- Priority queue for timestamp-ordered buffers +- Clock sync integration for timing +- Late frame detection and dropping +- Jitter buffer management +- Playback statistics tracking + +🤖 Generated with [Claude Code](https://claude.com/claude-code) + +Co-Authored-By: Claude " +``` + +--- + +## Task 7: Audio Output with Volume Control + +**Files:** +- Create: `internal/player/output.go` +- Create: `internal/player/output_test.go` + +**Step 1: Write test for volume control** + +Create `internal/player/output_test.go`: +```go +// ABOUTME: Tests for audio output +// ABOUTME: Tests volume control and PCM playback +package player + +import ( + "testing" +) + +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(t *testing.T) { + samples := []int16{1000, -1000, 500, -500} + volume := 50 + muted := false + + result := applyVolume(samples, volume, muted) + + if result[0] != 500 { + t.Errorf("expected 500, got %d", result[0]) + } + if result[1] != -500 { + t.Errorf("expected -500, got %d", result[1]) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: +```bash +go test ./internal/player/... -v +``` + +Expected: FAIL (undefined functions) + +**Step 3: Implement audio output** + +Create `internal/player/output.go`: +```go +// ABOUTME: Audio output using oto library +// ABOUTME: Handles PCM playback with software volume control +package player + +import ( + "context" + "encoding/binary" + "fmt" + "log" + + "github.com/Resonate-Protocol/resonate-go/internal/audio" + "github.com/ebitengine/oto/v3" +) + +// Output manages audio output +type Output struct { + ctx context.Context + cancel context.CancelFunc + otoCtx *oto.Context + player *oto.Player + format audio.Format + volume int + muted bool + ready bool +} + +// NewOutput creates an audio output +func NewOutput() *Output { + ctx, cancel := context.WithCancel(context.Background()) + + return &Output{ + ctx: ctx, + cancel: cancel, + volume: 100, + muted: false, + } +} + +// Initialize sets up oto with the specified format +func (o *Output) Initialize(format audio.Format) error { + if o.otoCtx != nil { + o.Close() + } + + op := &oto.NewContextOptions{ + SampleRate: format.SampleRate, + ChannelCount: format.Channels, + Format: oto.FormatSignedInt16LE, + } + + ctx, readyChan, err := oto.NewContext(op) + if err != nil { + return fmt.Errorf("failed to create oto context: %w", err) + } + + <-readyChan + + o.otoCtx = ctx + o.format = format + o.ready = true + + log.Printf("Audio output initialized: %dHz, %d channels", + format.SampleRate, format.Channels) + + return nil +} + +// Play plays an audio buffer +func (o *Output) Play(buf audio.Buffer) error { + if !o.ready { + return fmt.Errorf("output not initialized") + } + + // Convert bytes to int16 samples + samples := make([]int16, len(buf.Samples)/2) + for i := 0; i < len(samples); i++ { + samples[i] = int16(binary.LittleEndian.Uint16(buf.Samples[i*2:])) + } + + // Apply volume + samples = applyVolume(samples, o.volume, o.muted) + + // Convert back to bytes + output := make([]byte, len(buf.Samples)) + for i, sample := range samples { + binary.LittleEndian.PutUint16(output[i*2:], uint16(sample)) + } + + // Write to oto + player := o.otoCtx.NewPlayer(nil) + player.Write(output) + + return nil +} + +// SetVolume sets the volume (0-100) +func (o *Output) SetVolume(volume int) { + if volume < 0 { + volume = 0 + } + if volume > 100 { + volume = 100 + } + o.volume = volume + log.Printf("Volume set to %d", volume) +} + +// SetMuted sets mute state +func (o *Output) SetMuted(muted bool) { + o.muted = muted + log.Printf("Muted: %v", muted) +} + +// GetVolume returns current volume +func (o *Output) GetVolume() int { + return o.volume +} + +// IsMuted returns mute state +func (o *Output) IsMuted() bool { + return o.muted +} + +// Close closes the audio output +func (o *Output) Close() { + if o.otoCtx != nil { + o.otoCtx.Suspend() + o.ready = false + } + o.cancel() +} + +// applyVolume applies volume and mute to samples +func applyVolume(samples []int16, volume int, muted bool) []int16 { + multiplier := getVolumeMultiplier(volume, muted) + + result := make([]int16, len(samples)) + for i, sample := range samples { + result[i] = int16(float64(sample) * multiplier) + } + + return result +} + +// getVolumeMultiplier calculates volume multiplier +func getVolumeMultiplier(volume int, muted bool) float64 { + if muted { + return 0.0 + } + return float64(volume) / 100.0 +} +``` + +**Step 4: Install dependencies** + +Run: +```bash +go get github.com/ebitengine/oto/v3 +go mod tidy +``` + +Expected: Dependencies downloaded + +**Step 5: Run tests to verify they pass** + +Run: +```bash +go test ./internal/player/... -v +``` + +Expected: PASS (all tests) + +**Step 6: Commit** + +```bash +git add internal/player/ go.mod go.sum +git commit -m "feat: implement audio output with volume control + +- Audio playback using ebitengine/oto +- Software volume control (0-100) +- Mute functionality +- Dynamic format initialization +- PCM sample manipulation + +🤖 Generated with [Claude Code](https://claude.com/claude-code) + +Co-Authored-By: Claude " +``` + +--- + +## Task 8: mDNS Discovery + +**Files:** +- Create: `internal/discovery/mdns.go` +- Create: `internal/discovery/mdns_test.go` + +**Step 1: Write test for discovery manager** + +Create `internal/discovery/mdns_test.go`: +```go +// ABOUTME: Tests for mDNS discovery +// ABOUTME: Tests service advertisement and discovery +package discovery + +import ( + "testing" +) + +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") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: +```bash +go test ./internal/discovery/... -v +``` + +Expected: FAIL with package not found + +**Step 3: Implement mDNS discovery** + +Create `internal/discovery/mdns.go`: +```go +// ABOUTME: mDNS service discovery for Resonate Protocol +// ABOUTME: Handles both advertisement (server-initiated) and browsing (client-initiated) +package discovery + +import ( + "context" + "fmt" + "log" + "net" + "os" + + "github.com/hashicorp/mdns" +) + +// Config holds discovery configuration +type Config struct { + ServiceName string + Port int +} + +// Manager handles mDNS operations +type Manager struct { + config Config + ctx context.Context + cancel context.CancelFunc + servers chan *ServerInfo +} + +// ServerInfo describes a discovered server +type ServerInfo struct { + Name string + Host string + Port int +} + +// NewManager creates a discovery manager +func NewManager(config Config) *Manager { + ctx, cancel := context.WithCancel(context.Background()) + + return &Manager{ + config: config, + ctx: ctx, + cancel: cancel, + servers: make(chan *ServerInfo, 10), + } +} + +// Advertise advertises this player via mDNS +func (m *Manager) Advertise() error { + hostname, _ := os.Hostname() + + ips, err := getLocalIPs() + if err != nil { + return fmt.Errorf("failed to get local IPs: %w", err) + } + + service, err := mdns.NewMDNSService( + m.config.ServiceName, + "_resonate._tcp", + "", + "", + m.config.Port, + ips, + []string{"path=/resonate"}, + ) + if err != nil { + return fmt.Errorf("failed to create service: %w", err) + } + + server, err := mdns.NewServer(&mdns.Config{Zone: service}) + if err != nil { + return fmt.Errorf("failed to create mdns server: %w", err) + } + + log.Printf("Advertising mDNS service: %s on port %d", m.config.ServiceName, m.config.Port) + + go func() { + <-m.ctx.Done() + server.Shutdown() + }() + + return nil +} + +// Browse searches for Resonate servers +func (m *Manager) Browse() error { + go m.browseLoop() + return nil +} + +// browseLoop continuously browses for servers +func (m *Manager) browseLoop() { + for { + select { + case <-m.ctx.Done(): + return + default: + } + + entries := make(chan *mdns.ServiceEntry, 10) + + go func() { + for entry := range entries { + 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: "_resonate-server._tcp", + Domain: "local", + Timeout: 3, + Entries: entries, + } + + mdns.Query(params) + close(entries) + } +} + +// Servers returns the channel of discovered servers +func (m *Manager) Servers() <-chan *ServerInfo { + return m.servers +} + +// Stop stops the discovery manager +func (m *Manager) Stop() { + m.cancel() +} + +// getLocalIPs returns local IP addresses +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 +} +``` + +**Step 4: Install dependencies** + +Run: +```bash +go get github.com/hashicorp/mdns +go mod tidy +``` + +Expected: Dependencies downloaded + +**Step 5: Run tests to verify they pass** + +Run: +```bash +go test ./internal/discovery/... -v +``` + +Expected: PASS (1 test) + +**Step 6: Commit** + +```bash +git add internal/discovery/ go.mod go.sum +git commit -m "feat: implement mDNS discovery + +- Advertise as _resonate._tcp.local service +- Browse for _resonate-server._tcp.local servers +- Support both discovery modes simultaneously +- TXT record support (path=/resonate) +- Automatic local IP detection + +🤖 Generated with [Claude Code](https://claude.com/claude-code) + +Co-Authored-By: Claude " +``` + +--- + +## Task 9: TUI Implementation + +**Files:** +- Create: `internal/ui/tui.go` +- Create: `internal/ui/model.go` + +**Step 1: Create TUI model** + +Create `internal/ui/model.go`: +```go +// ABOUTME: Bubbletea model for player TUI +// ABOUTME: Defines application state and update logic +package ui + +import ( + "fmt" + + "github.com/Resonate-Protocol/resonate-go/internal/protocol" + "github.com/Resonate-Protocol/resonate-go/internal/sync" + tea "github.com/charmbracelet/bubbletea" +) + +// Model represents the TUI state +type Model struct { + // Connection + connected bool + serverName string + + // Sync + syncOffset int64 + syncRTT int64 + syncQuality sync.Quality + + // Stream + codec string + sampleRate int + channels int + bitDepth int + + // Metadata + title string + artist string + album string + + // Playback + state string + volume int + muted bool + + // Stats + received int64 + played int64 + dropped int64 + bufferDepth int + + // Debug + showDebug bool + + // Dimensions + width int + height int +} + +// Init initializes the model +func (m Model) Init() tea.Cmd { + return nil +} + +// Update handles messages +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) + } + + return m, nil +} + +// View renders the TUI +func (m Model) View() string { + if m.width == 0 { + return "Loading..." + } + + s := "" + s += m.renderHeader() + s += m.renderStreamInfo() + s += m.renderControls() + s += m.renderStats() + + if m.showDebug { + s += m.renderDebug() + } + + s += m.renderHelp() + + return s +} + +// renderHeader renders connection and sync status +func (m Model) renderHeader() string { + connStatus := "Disconnected" + if m.connected { + connStatus = fmt.Sprintf("Connected to %s", m.serverName) + } + + syncIcon := "✗" + syncText := "Lost" + switch m.syncQuality { + case sync.QualityGood: + syncIcon = "✓" + syncText = fmt.Sprintf("Synced (offset: %+.1fms, jitter: %.1fms)", + float64(m.syncOffset)/1000.0, float64(m.syncRTT)/1000.0) + case sync.QualityDegraded: + syncIcon = "⚠" + syncText = "Degraded" + } + + return fmt.Sprintf(`┌─ Resonate Player ────────────────────────────────────┐ +│ Status: %-45s │ +│ Sync: %s %-42s │ +├──────────────────────────────────────────────────────┤ +`, connStatus, syncIcon, syncText) +} + +// renderStreamInfo renders current stream and metadata +func (m Model) renderStreamInfo() string { + if !m.connected || m.codec == "" { + return "│ No stream │\n" + } + + s := "│ Now Playing: │\n" + if m.title != "" { + s += fmt.Sprintf("│ Track: %-42s │\n", truncate(m.title, 42)) + s += fmt.Sprintf("│ Artist: %-42s │\n", truncate(m.artist, 42)) + s += fmt.Sprintf("│ Album: %-42s │\n", truncate(m.album, 42)) + } else { + s += "│ (No metadata) │\n" + } + + s += "│ │\n" + s += fmt.Sprintf("│ Format: %s %dHz %s %d-bit%-17s │\n", + m.codec, m.sampleRate, channelName(m.channels), m.bitDepth, "") + + return s +} + +// renderControls renders volume and buffer status +func (m Model) renderControls() string { + muteIcon := "" + if m.muted { + muteIcon = " 🔇" + } + + volumeBar := renderBar(m.volume, 100, 10) + + return fmt.Sprintf("│ │\n"+ + "│ Volume: [%s] %d%%%s%-17s │\n"+ + "│ Buffer: %dms (%d chunks)%-24s │\n", + volumeBar, m.volume, muteIcon, "", + m.bufferDepth, m.bufferDepth/10, "") +} + +// renderStats renders playback statistics +func (m Model) renderStats() string { + return fmt.Sprintf(`├──────────────────────────────────────────────────────┤ +│ Stats: RX: %d Played: %d Dropped: %d%-8s │ +│ │ +`, m.received, m.played, m.dropped, "") +} + +// renderHelp renders keyboard shortcuts +func (m Model) renderHelp() string { + return `│ ↑/↓:Volume m:Mute r:Reconnect d:Debug q:Quit │ +└──────────────────────────────────────────────────────┘ +` +} + +// renderDebug renders debug information +func (m Model) renderDebug() string { + return fmt.Sprintf(`│ DEBUG: │ +│ Goroutines: (not tracked) │ +│ Channels: (not tracked) │ +│ Clock Offset: %+dμs │ +`, m.syncOffset) +} + +// handleKey handles keyboard input +func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + case "up": + if m.volume < 100 { + m.volume += 5 + if m.volume > 100 { + m.volume = 100 + } + } + case "down": + if m.volume > 0 { + m.volume -= 5 + if m.volume < 0 { + m.volume = 0 + } + } + case "m": + m.muted = !m.muted + case "d": + m.showDebug = !m.showDebug + } + + return m, nil +} + +// applyStatus updates model from status message +func (m *Model) applyStatus(msg StatusMsg) { + if msg.Connected != nil { + m.connected = *msg.Connected + } + if msg.ServerName != "" { + m.serverName = msg.ServerName + } + if msg.SyncOffset != 0 { + m.syncOffset = msg.SyncOffset + 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.Volume != 0 { + m.volume = msg.Volume + } + if msg.Received != 0 { + m.received = msg.Received + m.played = msg.Played + m.dropped = msg.Dropped + } +} + +// StatusMsg updates TUI state +type StatusMsg struct { + Connected *bool + ServerName string + SyncOffset int64 + SyncRTT int64 + SyncQuality sync.Quality + Codec string + SampleRate int + Channels int + BitDepth int + Title string + Artist string + Album string + Volume int + Received int64 + Played int64 + Dropped int64 +} + +// Utility functions +func renderBar(value, max, width int) string { + filled := (value * width) / max + bar := "" + for i := 0; i < width; i++ { + if i < filled { + bar += "█" + } else { + bar += "░" + } + } + return bar +} + +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" +} +``` + +**Step 2: Create TUI wrapper** + +Create `internal/ui/tui.go`: +```go +// ABOUTME: TUI initialization and control +// ABOUTME: Wraps bubbletea program for player UI +package ui + +import ( + tea "github.com/charmbracelet/bubbletea" +) + +// NewModel creates a new TUI model +func NewModel() Model { + return Model{ + volume: 100, + state: "idle", + } +} + +// Run starts the TUI +func Run() (*tea.Program, error) { + p := tea.NewProgram(NewModel(), tea.WithAltScreen()) + return p, nil +} +``` + +**Step 3: Install dependencies** + +Run: +```bash +go get github.com/charmbracelet/bubbletea +go mod tidy +``` + +Expected: Dependencies downloaded + +**Step 4: Test build** + +Run: +```bash +go build -o resonate-player +``` + +Expected: Builds successfully + +**Step 5: Commit** + +```bash +git add internal/ui/ go.mod go.sum +git commit -m "feat: implement interactive TUI + +- Bubbletea-based terminal UI +- Display connection, sync, stream status +- Show metadata (track, artist, album) +- Volume control display +- Playback statistics +- Keyboard controls (↑/↓, m, d, q) +- Debug panel toggle + +🤖 Generated with [Claude Code](https://claude.com/claude-code) + +Co-Authored-By: Claude " +``` + +--- + +## Task 10: Integration and Main Loop + +**Files:** +- Modify: `main.go` +- Create: `internal/app/player.go` + +**Step 1: Create player application** + +Create `internal/app/player.go`: +```go +// ABOUTME: Main player application orchestration +// ABOUTME: Coordinates all components (connection, audio, UI) +package app + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/Resonate-Protocol/resonate-go/internal/audio" + "github.com/Resonate-Protocol/resonate-go/internal/client" + "github.com/Resonate-Protocol/resonate-go/internal/discovery" + "github.com/Resonate-Protocol/resonate-go/internal/player" + "github.com/Resonate-Protocol/resonate-go/internal/protocol" + "github.com/Resonate-Protocol/resonate-go/internal/sync" + "github.com/Resonate-Protocol/resonate-go/internal/ui" + "github.com/Resonate-Protocol/resonate-go/internal/version" + "github.com/google/uuid" + tea "github.com/charmbracelet/bubbletea" +) + +// Config holds player configuration +type Config struct { + ServerAddr string + Port int + Name string + BufferMs int +} + +// Player represents the main player application +type Player struct { + config Config + client *client.Client + clockSync *sync.ClockSync + scheduler *player.Scheduler + output *player.Output + discovery *discovery.Manager + decoder audio.Decoder + tuiProg *tea.Program + ctx context.Context + cancel context.CancelFunc +} + +// New creates a new player +func New(config Config) *Player { + ctx, cancel := context.WithCancel(context.Background()) + + return &Player{ + config: config, + clockSync: sync.NewClockSync(), + output: player.NewOutput(), + ctx: ctx, + cancel: cancel, + } +} + +// Start starts the player +func (p *Player) Start() error { + // Start TUI + tuiProg, err := ui.Run() + if err != nil { + return fmt.Errorf("failed to start TUI: %w", err) + } + p.tuiProg = tuiProg + + go p.tuiProg.Run() + + // Start discovery if no manual server + if p.config.ServerAddr == "" { + p.discovery = discovery.NewManager(discovery.Config{ + ServiceName: p.config.Name, + Port: p.config.Port, + }) + + p.discovery.Advertise() + p.discovery.Browse() + + // Wait for server discovery + go p.handleDiscovery() + } else { + // Connect directly + if err := p.connect(p.config.ServerAddr); err != nil { + return fmt.Errorf("connection failed: %w", err) + } + } + + // Wait for context cancellation + <-p.ctx.Done() + + return nil +} + +// handleDiscovery waits for server discovery +func (p *Player) handleDiscovery() { + for { + select { + case server := <-p.discovery.Servers(): + addr := fmt.Sprintf("%s:%d", server.Host, server.Port) + log.Printf("Attempting connection to %s", addr) + + if err := p.connect(addr); err != nil { + log.Printf("Connection failed: %v", err) + continue + } + return + + case <-p.ctx.Done(): + return + } + } +} + +// connect establishes connection to server +func (p *Player) connect(serverAddr string) error { + clientID := uuid.New().String() + + clientConfig := client.Config{ + ServerAddr: serverAddr, + ClientID: clientID, + Name: p.config.Name, + Version: 1, + DeviceInfo: protocol.DeviceInfo{ + ProductName: version.Product, + Manufacturer: version.Manufacturer, + SoftwareVersion: version.Version, + }, + PlayerSupport: protocol.PlayerSupport{ + Codecs: []string{"opus", "flac", "pcm"}, + SampleRates: []int{44100, 48000}, + Channels: []int{1, 2}, + BitDepths: []int{16, 24}, + }, + } + + p.client = client.NewClient(clientConfig) + + if err := p.client.Connect(); err != nil { + return err + } + + log.Printf("Connected to server: %s", serverAddr) + + // Start component goroutines + go p.handleAudioChunks() + go p.handleControls() + go p.handleStreamStart() + go p.handleMetadata() + go p.clockSyncLoop() + + return nil +} + +// clockSyncLoop continuously syncs clock +func (p *Player) clockSyncLoop() { + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + t1 := sync.CurrentMicros() + p.client.SendTimeSync(t1) + + // Wait for response + select { + case resp := <-p.client.TimeSyncResp: + t4 := sync.CurrentMicros() + p.clockSync.ProcessSyncResponse(resp.T1, resp.T2, resp.T3, t4) + + case <-time.After(2 * time.Second): + log.Printf("Time sync timeout") + } + + case <-p.ctx.Done(): + return + } + } +} + +// handleStreamStart initializes decoder and output +func (p *Player) handleStreamStart() { + for { + select { + case start := <-p.client.StreamStart: + log.Printf("Stream starting: %s %dHz %dch %dbit", + start.Codec, start.SampleRate, start.Channels, start.BitDepth) + + format := audio.Format{ + Codec: start.Codec, + SampleRate: start.SampleRate, + Channels: start.Channels, + BitDepth: start.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 + } + + // Initialize scheduler + p.scheduler = player.NewScheduler(p.clockSync, p.config.BufferMs) + go p.scheduler.Run() + go p.handleScheduledAudio() + + case <-p.ctx.Done(): + return + } + } +} + +// handleAudioChunks decodes and schedules audio +func (p *Player) handleAudioChunks() { + for { + select { + case chunk := <-p.client.AudioChunks: + if p.decoder == nil || p.scheduler == nil { + continue + } + + // Decode + pcm, err := p.decoder.Decode(chunk.Data) + if err != nil { + log.Printf("Decode error: %v", err) + continue + } + + // Schedule + buf := audio.Buffer{ + Timestamp: chunk.Timestamp, + Samples: pcm, + } + p.scheduler.Schedule(buf) + + case <-p.ctx.Done(): + return + } + } +} + +// handleScheduledAudio plays scheduled buffers +func (p *Player) handleScheduledAudio() { + for { + select { + case buf := <-p.scheduler.Output(): + if err := p.output.Play(buf); err != nil { + log.Printf("Playback error: %v", err) + } + + case <-p.ctx.Done(): + return + } + } +} + +// handleControls processes server commands +func (p *Player) handleControls() { + for { + select { + case cmd := <-p.client.ControlMsgs: + switch cmd.Command { + case "volume": + p.output.SetVolume(cmd.Volume) + p.client.SendState(protocol.ClientState{Volume: cmd.Volume}) + + case "mute": + p.output.SetMuted(cmd.Mute) + p.client.SendState(protocol.ClientState{Muted: cmd.Mute}) + } + + case <-p.ctx.Done(): + return + } + } +} + +// handleMetadata updates UI with track info +func (p *Player) handleMetadata() { + for { + select { + case meta := <-p.client.Metadata: + log.Printf("Metadata: %s - %s (%s)", meta.Artist, meta.Title, meta.Album) + // TODO: Send to TUI + + case <-p.ctx.Done(): + return + } + } +} + +// Stop stops the player +func (p *Player) Stop() { + p.cancel() + + if p.client != nil { + p.client.Close() + } + + if p.output != nil { + p.output.Close() + } + + if p.tuiProg != nil { + p.tuiProg.Quit() + } +} +``` + +**Step 2: Update main.go** + +Modify `main.go`: +```go +// ABOUTME: Entry point for Resonate Protocol player +// ABOUTME: Parses CLI flags and starts the player application +package main + +import ( + "flag" + "fmt" + "log" + "os" + "os/signal" + "syscall" + + "github.com/Resonate-Protocol/resonate-go/internal/app" +) + +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-resonate-player)") + bufferMs = flag.Int("buffer-ms", 150, "Jitter buffer size in milliseconds") + logFile = flag.String("log-file", "resonate-player.log", "Log file path") + debug = flag.Bool("debug", false, "Enable debug logging") +) + +func main() { + flag.Parse() + + // Set up logging + f, err := os.OpenFile(*logFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) + if err != nil { + log.Fatalf("error opening log file: %v", err) + } + defer f.Close() + log.SetOutput(f) + + // Determine player name + playerName := *name + if playerName == "" { + hostname, err := os.Hostname() + if err != nil { + hostname = "unknown" + } + playerName = fmt.Sprintf("%s-resonate-player", hostname) + } + + log.Printf("Starting Resonate Player: %s", playerName) + + // Create player + config := app.Config{ + ServerAddr: *serverAddr, + Port: *port, + Name: playerName, + BufferMs: *bufferMs, + } + + player := app.New(config) + + // Handle shutdown + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + + go func() { + <-sigChan + log.Printf("Shutdown signal received") + player.Stop() + }() + + // Start player + if err := player.Start(); err != nil { + log.Fatalf("Player error: %v", err) + } + + log.Printf("Player stopped") +} +``` + +**Step 3: Install remaining dependencies** + +Run: +```bash +go get github.com/google/uuid +go mod tidy +``` + +Expected: Dependencies downloaded + +**Step 4: Build the player** + +Run: +```bash +go build -o resonate-player +``` + +Expected: Builds successfully with no errors + +**Step 5: Test basic startup** + +Run: +```bash +./resonate-player --help +``` + +Expected: Shows help with all options + +**Step 6: Commit** + +```bash +git add main.go internal/app/ go.mod go.sum +git commit -m "feat: integrate all components into main player app + +- Orchestrate connection, discovery, audio, and UI +- Clock sync loop with 1s interval +- Audio pipeline: chunks → decode → schedule → play +- Handle stream start, metadata, controls +- Graceful shutdown on SIGINT/SIGTERM + +🤖 Generated with [Claude Code](https://claude.com/claude-code) + +Co-Authored-By: Claude " +``` + +--- + +## Summary + +The implementation plan is complete! The player now includes: + +✅ Project structure and initialization +✅ Protocol message types +✅ WebSocket client with handshake +✅ Clock synchronization (NTP-style) +✅ Multi-codec audio decoder (Opus/FLAC/PCM) +✅ Timestamp-based playback scheduler +✅ Audio output with volume control +✅ mDNS discovery (both modes) +✅ Interactive TUI +✅ Full integration in main application + +## Next Steps + +1. **Testing**: Test with a real Music Assistant server +2. **FLAC**: Complete FLAC streaming decoder implementation +3. **Polish**: Refine TUI updates, add artwork support +4. **Performance**: Profile and optimize for low latency +5. **Packaging**: Create installers/packages for distribution + +## Running the Player + +```bash +# Auto-discovery mode +./resonate-player --name "Living Room" + +# Manual connection +./resonate-player --server music-assistant.local:8927 --name "Bedroom" + +# With custom buffer +./resonate-player --buffer-ms 200 --debug +``` diff --git a/third_party/sendspin-go/docs/plans/2025-10-25-library-refactor-design.md b/third_party/sendspin-go/docs/plans/2025-10-25-library-refactor-design.md new file mode 100644 index 0000000..5df6c13 --- /dev/null +++ b/third_party/sendspin-go/docs/plans/2025-10-25-library-refactor-design.md @@ -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 diff --git a/third_party/sendspin-go/docs/plans/2025-10-25-library-refactor-plan.md b/third_party/sendspin-go/docs/plans/2025-10-25-library-refactor-plan.md new file mode 100644 index 0000000..dfd9f57 --- /dev/null +++ b/third_party/sendspin-go/docs/plans/2025-10-25-library-refactor-plan.md @@ -0,0 +1,1565 @@ +# Library Refactor Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Convert resonate-go from CLI-focused to library-first architecture with layered public APIs + +**Architecture:** Ground-up restructure moving code from `internal/` to `pkg/` with three layers: high-level convenience API (`pkg/resonate/`), component APIs (`pkg/audio/`, `pkg/protocol/`, etc.), and private implementation (`internal/`). CLI tools become thin wrappers using the public library. + +**Tech Stack:** Go 1.23, existing audio libraries (PortAudio, Opus, FLAC, MP3), WebSockets + +**Design Document:** `docs/plans/2025-10-25-library-refactor-design.md` + +--- + +## Task 1: Create Package Structure + +**Files:** +- Create: `pkg/audio/doc.go` +- Create: `pkg/audio/decode/doc.go` +- Create: `pkg/audio/encode/doc.go` +- Create: `pkg/audio/resample/doc.go` +- Create: `pkg/audio/output/doc.go` +- Create: `pkg/protocol/doc.go` +- Create: `pkg/sync/doc.go` +- Create: `pkg/discovery/doc.go` +- Create: `pkg/resonate/doc.go` + +**Step 1: Create pkg/audio directory with package documentation** + +```bash +mkdir -p pkg/audio +``` + +**Step 2: Write pkg/audio/doc.go** + +Create: `pkg/audio/doc.go` + +```go +// 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 resonate 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 +``` + +**Step 3: Create remaining package directories** + +```bash +mkdir -p pkg/audio/decode +mkdir -p pkg/audio/encode +mkdir -p pkg/audio/resample +mkdir -p pkg/audio/output +mkdir -p pkg/protocol +mkdir -p pkg/sync +mkdir -p pkg/discovery +mkdir -p pkg/resonate +``` + +**Step 4: Write package documentation for each** + +Create: `pkg/audio/decode/doc.go` + +```go +// ABOUTME: Audio decoder package for multiple codec support +// ABOUTME: Provides Decoder interface and implementations for PCM, Opus, FLAC, MP3 +// Package decode provides audio decoders for various codecs. +// +// Supports: PCM (16-bit and 24-bit), Opus, FLAC, MP3 +// +// 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 +``` + +Create: `pkg/audio/encode/doc.go` + +```go +// 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 +``` + +Create: `pkg/audio/resample/doc.go` + +```go +// ABOUTME: Audio resampling package using linear interpolation +// ABOUTME: Converts audio between different sample rates +// Package resample provides audio sample rate conversion. +// +// Uses linear interpolation for converting between sample rates. +// Handles both upsampling and downsampling. +// +// Example: +// +// r := resample.New(44100, 48000, 2) +// outputSize := r.Resample(inputSamples, outputSamples) +package resample +``` + +Create: `pkg/audio/output/doc.go` + +```go +// ABOUTME: Audio output package for playing audio +// ABOUTME: Provides Output interface and PortAudio implementation +// Package output provides audio playback interfaces. +// +// Currently supports PortAudio for cross-platform audio output. +// +// Example: +// +// out := output.NewPortAudio() +// err := out.Open(48000, 2) +// err = out.Write(samples) +package output +``` + +Create: `pkg/protocol/doc.go` + +```go +// ABOUTME: Resonate wire protocol package +// ABOUTME: Defines protocol messages and WebSocket client +// Package protocol implements the Resonate wire protocol. +// +// Provides message types and WebSocket client for communicating +// with Resonate servers. +// +// Example: +// +// client, err := protocol.NewClient("localhost:8927") +// err = client.SendHello(helloMsg) +package protocol +``` + +Create: `pkg/sync/doc.go` + +```go +// ABOUTME: Clock synchronization package +// ABOUTME: Provides NTP-style clock sync with Resonate servers +// Package sync provides clock synchronization for precise audio timing. +// +// Uses NTP-style round-trip time measurement to sync with server clocks. +// +// Example: +// +// clock := sync.NewClock() +// err := clock.Sync("localhost:8927") +// serverTime := clock.ServerTime() +package sync +``` + +Create: `pkg/discovery/doc.go` + +```go +// 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 +``` + +Create: `pkg/resonate/doc.go` + +```go +// ABOUTME: High-level Resonate library API +// ABOUTME: Provides simple Player and Server APIs for most use cases +// Package resonate provides high-level APIs for Resonate audio streaming. +// +// This is the main entry point for most library users, providing: +// - Player: Connect to servers and play synchronized audio +// - Server: Serve audio to multiple clients +// - AudioSource: Interface for custom audio sources +// +// For lower-level control, see the audio, protocol, sync, and discovery packages. +// +// Example Player: +// +// player, err := resonate.NewPlayer(resonate.PlayerConfig{ +// ServerAddr: "localhost:8927", +// PlayerName: "Living Room", +// Volume: 80, +// }) +// err = player.Connect() +// err = player.Play() +// +// Example Server: +// +// source, err := resonate.FileSource("/path/to/audio.flac") +// server, err := resonate.NewServer(resonate.ServerConfig{ +// Port: 8927, +// Source: source, +// }) +// err = server.Start() +package resonate +``` + +**Step 5: Verify package structure** + +```bash +tree pkg +``` + +Expected: All directories created with doc.go files + +**Step 6: Commit package structure** + +```bash +git add pkg/ +git commit -m "feat: Create public package structure for library + +- Add pkg/audio/ for audio primitives +- Add pkg/audio/decode/ for decoders +- Add pkg/audio/encode/ for encoders +- Add pkg/audio/resample/ for resampling +- Add pkg/audio/output/ for audio output +- Add pkg/protocol/ for wire protocol +- Add pkg/sync/ for clock synchronization +- Add pkg/discovery/ for mDNS discovery +- Add pkg/resonate/ for high-level API + +Each package includes comprehensive documentation." +``` + +--- + +## Task 2: Move Audio Types to pkg/audio + +**Files:** +- Create: `pkg/audio/types.go` +- Create: `pkg/audio/types_test.go` +- Reference: `internal/audio/types.go` (source) + +**Step 1: Copy types.go from internal to pkg** + +Create: `pkg/audio/types.go` + +```go +// 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 { + // Right-shift to convert 24-bit (or 16-bit) to 16-bit range + return int16(sample >> 8) +} + +// SampleFromInt16 converts int16 sample to int32 (left-justified in 24-bit) +func SampleFromInt16(sample int16) int32 { + // Left-shift to position 16-bit value in upper bits + return int32(sample) << 8 +} + +// SampleTo24Bit converts int32 to 24-bit packed bytes (little-endian) +func SampleTo24Bit(sample int32) [3]byte { + // Take lower 24 bits, pack little-endian + 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 { + // Reconstruct 24-bit value and sign-extend to 32-bit + 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 // Set upper 8 bits to 1 for negative values + } + return val +} +``` + +**Step 2: Write tests for conversion functions** + +Create: `pkg/audio/types_test.go` + +```go +// 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) + } + } +} +``` + +**Step 3: Run tests** + +```bash +go test -v ./pkg/audio +``` + +Expected: All tests pass + +**Step 4: Commit audio types** + +```bash +git add pkg/audio/types.go pkg/audio/types_test.go +git commit -m "feat: Add audio types to public API + +- Add Format and Buffer types +- Add 24-bit audio constants +- Add sample conversion functions +- Add comprehensive tests for conversions" +``` + +--- + +## Task 3: Move PCM Decoder to pkg/audio/decode + +**Files:** +- Create: `pkg/audio/decode/decoder.go` +- Create: `pkg/audio/decode/pcm.go` +- Create: `pkg/audio/decode/pcm_test.go` +- Reference: `internal/audio/decoder.go` (source) + +**Step 1: Create decoder interface** + +Create: `pkg/audio/decode/decoder.go` + +```go +// 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 converts encoded audio data to PCM samples + Decode(data []byte) ([]int32, error) + + // Close releases decoder resources + Close() error +} +``` + +**Step 2: Write failing test for PCM decoder** + +Create: `pkg/audio/decode/pcm_test.go` + +```go +// ABOUTME: Tests for PCM decoder +// ABOUTME: Tests 16-bit and 24-bit PCM decoding +package decode + +import ( + "testing" + + "github.com/harperreed/resonate-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) + } + + // PCM converts bytes to int16 samples (little-endian) + // Input: 4 bytes -> Output: 2 int16 samples + 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) + } + + // 24-bit PCM: 3 bytes per sample + // Input: 6 bytes -> Output: 2 samples + 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]) + } +} +``` + +**Step 3: Run test to verify it fails** + +```bash +go test -v ./pkg/audio/decode +``` + +Expected: FAIL - undefined: NewPCM + +**Step 4: Implement PCM decoder** + +Create: `pkg/audio/decode/pcm.go` + +```go +// ABOUTME: PCM audio decoder +// ABOUTME: Decodes 16-bit and 24-bit PCM audio to int32 samples +package decode + +import ( + "encoding/binary" + "fmt" + + "github.com/harperreed/resonate-go/pkg/audio" +) + +// PCMDecoder decodes PCM audio +type PCMDecoder struct { + bitDepth int +} + +// NewPCM creates a new PCM decoder +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 +} + +// Decode converts PCM bytes to int32 samples +func (d *PCMDecoder) Decode(data []byte) ([]int32, error) { + if d.bitDepth == 24 { + // 24-bit PCM: 3 bytes per sample + 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 { + // 16-bit PCM: 2 bytes per sample (default) + 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 + } +} + +// Close releases resources +func (d *PCMDecoder) Close() error { + return nil +} +``` + +**Step 5: Run tests to verify they pass** + +```bash +go test -v ./pkg/audio/decode +``` + +Expected: PASS - all tests pass + +**Step 6: Commit PCM decoder** + +```bash +git add pkg/audio/decode/ +git commit -m "feat: Add PCM decoder to public API + +- Add Decoder interface +- Add PCM decoder supporting 16-bit and 24-bit +- Add comprehensive tests" +``` + +--- + +## Task 4: Move Remaining Decoders to pkg/audio/decode + +**Files:** +- Create: `pkg/audio/decode/opus.go` +- Create: `pkg/audio/decode/flac.go` +- Create: `pkg/audio/decode/mp3.go` +- Reference: `internal/audio/decoder.go` (source) + +**Step 1: Copy Opus decoder from internal** + +Create: `pkg/audio/decode/opus.go` + +```go +// ABOUTME: Opus audio decoder +// ABOUTME: Decodes Opus audio to int32 samples +package decode + +import ( + "fmt" + + "github.com/harperreed/resonate-go/pkg/audio" + "github.com/hraban/opus" +) + +// OpusDecoder decodes Opus audio +type OpusDecoder struct { + decoder *opus.Decoder + sampleRate int + channels int +} + +// NewOpus creates a new Opus decoder +func NewOpus(format audio.Format) (Decoder, error) { + if format.Codec != "opus" { + return nil, fmt.Errorf("invalid codec for Opus decoder: %s", format.Codec) + } + + decoder, err := opus.NewDecoder(format.SampleRate, format.Channels) + if err != nil { + return nil, fmt.Errorf("failed to create opus decoder: %w", err) + } + + return &OpusDecoder{ + decoder: decoder, + sampleRate: format.SampleRate, + channels: format.Channels, + }, nil +} + +// Decode converts Opus bytes to int32 samples +func (d *OpusDecoder) Decode(data []byte) ([]int32, error) { + // Opus decoder outputs int16 + pcm := make([]int16, 5760*d.channels) // Max Opus frame size + n, err := d.decoder.Decode(data, pcm) + if err != nil { + return nil, fmt.Errorf("opus decode error: %w", err) + } + + // Convert int16 to int32 (24-bit range) + samples := make([]int32, n*d.channels) + for i := 0; i < n*d.channels; i++ { + samples[i] = audio.SampleFromInt16(pcm[i]) + } + + return samples, nil +} + +// Close releases resources +func (d *OpusDecoder) Close() error { + return nil +} +``` + +**Step 2: Copy FLAC decoder from internal** + +Create: `pkg/audio/decode/flac.go` + +```go +// ABOUTME: FLAC audio decoder +// ABOUTME: Decodes FLAC audio to int32 samples +package decode + +import ( + "fmt" + "io" + + "github.com/harperreed/resonate-go/pkg/audio" + "github.com/mewkiz/flac" +) + +// FLACDecoder decodes FLAC audio +type FLACDecoder struct { + stream *flac.Stream +} + +// NewFLAC creates a new FLAC decoder +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 codec header") + } + + // Create FLAC stream from header + // Note: This is a simplified version - real implementation needs + // to handle streaming FLAC data properly + return &FLACDecoder{}, fmt.Errorf("FLAC decoder not yet implemented") +} + +// Decode converts FLAC bytes to int32 samples +func (d *FLACDecoder) Decode(data []byte) ([]int32, error) { + return nil, fmt.Errorf("FLAC decode not yet implemented") +} + +// Close releases resources +func (d *FLACDecoder) Close() error { + if d.stream != nil { + // Close stream + } + return nil +} +``` + +**Step 3: Copy MP3 decoder from internal** + +Create: `pkg/audio/decode/mp3.go` + +```go +// ABOUTME: MP3 audio decoder +// ABOUTME: Decodes MP3 audio to int32 samples +package decode + +import ( + "fmt" + + "github.com/harperreed/resonate-go/pkg/audio" + "github.com/tosone/minimp3" +) + +// MP3Decoder decodes MP3 audio +type MP3Decoder struct { + decoder *minimp3.Decoder +} + +// NewMP3 creates a new MP3 decoder +func NewMP3(format audio.Format) (Decoder, error) { + if format.Codec != "mp3" { + return nil, fmt.Errorf("invalid codec for MP3 decoder: %s", format.Codec) + } + + decoder, err := minimp3.NewDecoder(nil) + if err != nil { + return nil, fmt.Errorf("failed to create mp3 decoder: %w", err) + } + + return &MP3Decoder{ + decoder: decoder, + }, nil +} + +// Decode converts MP3 bytes to int32 samples +func (d *MP3Decoder) Decode(data []byte) ([]int32, error) { + // MP3 decoder outputs int16 + pcm, err := d.decoder.Read(data) + if err != nil { + return nil, fmt.Errorf("mp3 decode error: %w", err) + } + + // Convert int16 to int32 (24-bit range) + samples := make([]int32, len(pcm)) + for i, sample := range pcm { + samples[i] = audio.SampleFromInt16(sample) + } + + return samples, nil +} + +// Close releases resources +func (d *MP3Decoder) Close() error { + if d.decoder != nil { + return d.decoder.Close() + } + return nil +} +``` + +**Step 4: Build to verify compilation** + +```bash +go build ./pkg/audio/decode +``` + +Expected: Success (FLAC decoder will have unused imports warning - that's OK for now) + +**Step 5: Commit decoders** + +```bash +git add pkg/audio/decode/opus.go pkg/audio/decode/flac.go pkg/audio/decode/mp3.go +git commit -m "feat: Add Opus, FLAC, MP3 decoders to public API + +- Add Opus decoder with int16 -> int32 conversion +- Add FLAC decoder stub (needs streaming implementation) +- Add MP3 decoder with int16 -> int32 conversion" +``` + +--- + +## Task 5: Move Encoders to pkg/audio/encode + +**Files:** +- Create: `pkg/audio/encode/encoder.go` +- Create: `pkg/audio/encode/pcm.go` +- Create: `pkg/audio/encode/opus.go` +- Reference: `internal/server/audio_engine.go` (PCM encoder) +- Reference: `internal/server/opus_encoder.go` (Opus encoder) + +**Step 1: Create encoder interface** + +Create: `pkg/audio/encode/encoder.go` + +```go +// 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 converts PCM samples to encoded audio data + Encode(samples []int32) ([]byte, error) + + // Close releases encoder resources + Close() error +} +``` + +**Step 2: Implement PCM encoder** + +Create: `pkg/audio/encode/pcm.go` + +```go +// ABOUTME: PCM audio encoder +// ABOUTME: Encodes int32 samples to 16-bit or 24-bit PCM bytes +package encode + +import ( + "encoding/binary" + "fmt" + + "github.com/harperreed/resonate-go/pkg/audio" +) + +// PCMEncoder encodes PCM audio +type PCMEncoder struct { + bitDepth int +} + +// NewPCM creates a new PCM encoder +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 +} + +// Encode converts int32 samples to PCM bytes +func (e *PCMEncoder) Encode(samples []int32) ([]byte, error) { + if e.bitDepth == 24 { + // 24-bit PCM: 3 bytes per sample + 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 { + // 16-bit PCM: 2 bytes per sample + 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 + } +} + +// Close releases resources +func (e *PCMEncoder) Close() error { + return nil +} +``` + +**Step 3: Copy Opus encoder from internal** + +Create: `pkg/audio/encode/opus.go` + +```go +// ABOUTME: Opus audio encoder +// ABOUTME: Encodes int32 samples to Opus bytes +package encode + +import ( + "fmt" + + "github.com/harperreed/resonate-go/pkg/audio" + "gopkg.in/hraban/opus.v2" +) + +// OpusEncoder encodes Opus audio +type OpusEncoder struct { + encoder *opus.Encoder + sampleRate int + channels int + frameSize int +} + +// NewOpus creates a new Opus encoder +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, + }, nil +} + +// Encode converts int32 samples to Opus bytes +func (e *OpusEncoder) Encode(samples []int32) ([]byte, error) { + // Convert int32 to int16 for Opus + pcm := make([]int16, len(samples)) + for i, sample := range samples { + pcm[i] = audio.SampleToInt16(sample) + } + + // Encode to Opus + data := make([]byte, 4000) // Max Opus packet size + n, err := e.encoder.Encode(pcm, data) + if err != nil { + return nil, fmt.Errorf("opus encode error: %w", err) + } + + return data[:n], nil +} + +// Close releases resources +func (e *OpusEncoder) Close() error { + return nil +} +``` + +**Step 4: Build to verify** + +```bash +go build ./pkg/audio/encode +``` + +Expected: Success + +**Step 5: Commit encoders** + +```bash +git add pkg/audio/encode/ +git commit -m "feat: Add PCM and Opus encoders to public API + +- Add Encoder interface +- Add PCM encoder supporting 16-bit and 24-bit +- Add Opus encoder with int32 -> int16 conversion" +``` + +--- + +## Task 6: Move Resampler to pkg/audio/resample + +**Files:** +- Create: `pkg/audio/resample/resampler.go` +- Create: `pkg/audio/resample/resampler_test.go` +- Reference: `internal/server/resampler.go` (source) +- Reference: `internal/server/resampler_test.go` (test source) + +**Step 1: Copy resampler from internal** + +Create: `pkg/audio/resample/resampler.go` + +```go +// ABOUTME: Audio resampler using linear interpolation +// ABOUTME: Converts audio between different sample rates +package resample + +// Resampler converts audio between sample rates using linear interpolation +type Resampler struct { + inputRate int + outputRate int + channels int + position float64 +} + +// New creates a new resampler +func New(inputRate, outputRate, channels int) *Resampler { + return &Resampler{ + inputRate: inputRate, + outputRate: outputRate, + channels: channels, + position: 0, + } +} + +// Resample converts input samples to output sample rate +// Returns number of samples written to output +func (r *Resampler) Resample(input, output []int32) int { + if len(input) == 0 { + return 0 + } + + ratio := float64(r.inputRate) / float64(r.outputRate) + outputSamples := 0 + inputFrames := len(input) / r.channels + + for outputSamples < len(output)/r.channels { + // Get interpolation position + inputPos := r.position * ratio + inputFrame := int(inputPos) + + if inputFrame >= inputFrames-1 { + break + } + + // Linear interpolation + frac := inputPos - float64(inputFrame) + + for ch := 0; ch < r.channels; ch++ { + idx1 := inputFrame*r.channels + ch + idx2 := (inputFrame+1)*r.channels + ch + + sample1 := float64(input[idx1]) + sample2 := float64(input[idx2]) + + interpolated := sample1 + (sample2-sample1)*frac + output[outputSamples*r.channels+ch] = int32(interpolated) + } + + outputSamples++ + r.position++ + } + + return outputSamples * r.channels +} +``` + +**Step 2: Copy tests from internal** + +Create: `pkg/audio/resample/resampler_test.go` + +```go +// ABOUTME: Tests for audio resampler +// ABOUTME: Tests linear interpolation resampling between sample rates +package resample + +import ( + "testing" +) + +func TestNew(t *testing.T) { + r := New(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 := New(44100, 48000, 2) + + // Input: 100 stereo samples (200 int32 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 := New(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 := New(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 TestResampleEmptyInput(t *testing.T) { + r := New(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) + } +} + +// Helper function +func abs(x int) int { + if x < 0 { + return -x + } + return x +} +``` + +**Step 3: Run tests** + +```bash +go test -v ./pkg/audio/resample +``` + +Expected: All tests pass + +**Step 4: Commit resampler** + +```bash +git add pkg/audio/resample/ +git commit -m "feat: Add resampler to public API + +- Add Resampler using linear interpolation +- Support upsampling and downsampling +- Add comprehensive tests" +``` + +--- + +## Task 7: Move Audio Output to pkg/audio/output + +**Files:** +- Create: `pkg/audio/output/output.go` +- Create: `pkg/audio/output/portaudio.go` +- Reference: `internal/player/output.go` (source) + +**Step 1: Create output interface** + +Create: `pkg/audio/output/output.go` + +```go +// ABOUTME: Audio output interface definition +// ABOUTME: Common interface for audio playback backends +package output + +// Output represents an audio output device +type Output interface { + // Open initializes the output device + Open(sampleRate, channels int) error + + // Write outputs audio samples (blocks until written) + Write(samples []int32) error + + // Close releases output resources + Close() error +} +``` + +**Step 2: Copy PortAudio implementation from internal** + +Create: `pkg/audio/output/portaudio.go` + +```go +// ABOUTME: PortAudio output implementation +// ABOUTME: Cross-platform audio output using PortAudio +package output + +import ( + "fmt" + + "github.com/gordonklaus/portaudio" + "github.com/harperreed/resonate-go/pkg/audio" +) + +// PortAudio output implementation +type PortAudio struct { + stream *portaudio.Stream + buffer []int16 +} + +// NewPortAudio creates a new PortAudio output +func NewPortAudio() Output { + return &PortAudio{} +} + +// Open initializes PortAudio +func (p *PortAudio) Open(sampleRate, channels int) error { + if err := portaudio.Initialize(); err != nil { + return fmt.Errorf("failed to initialize portaudio: %w", err) + } + + stream, err := portaudio.OpenDefaultStream(0, channels, float64(sampleRate), 0, func(out []int16) { + copy(out, p.buffer) + }) + if err != nil { + portaudio.Terminate() + return fmt.Errorf("failed to open stream: %w", err) + } + + p.stream = stream + return stream.Start() +} + +// Write outputs audio samples +func (p *PortAudio) Write(samples []int32) error { + if p.stream == nil { + return fmt.Errorf("output not opened") + } + + // Convert int32 to int16 for PortAudio + p.buffer = make([]int16, len(samples)) + for i, sample := range samples { + p.buffer[i] = audio.SampleToInt16(sample) + } + + return nil +} + +// Close releases resources +func (p *PortAudio) Close() error { + if p.stream != nil { + if err := p.stream.Stop(); err != nil { + return err + } + if err := p.stream.Close(); err != nil { + return err + } + } + return portaudio.Terminate() +} +``` + +**Step 3: Build to verify** + +```bash +go build ./pkg/audio/output +``` + +Expected: Success + +**Step 4: Commit audio output** + +```bash +git add pkg/audio/output/ +git commit -m "feat: Add audio output to public API + +- Add Output interface +- Add PortAudio implementation +- Convert int32 samples to int16 for playback" +``` + +--- + +Due to length constraints, I'll continue with the remaining tasks in a summary format: + +## Remaining Tasks Summary + +### Task 8: Move Protocol to pkg/protocol +- Copy message types from `internal/protocol/messages.go` +- Copy WebSocket client from `internal/client/websocket.go` +- Update imports to use `pkg/audio` + +### Task 9: Move Sync to pkg/sync +- Copy clock implementation from `internal/sync/clock.go` +- Copy tests from `internal/sync/clock_test.go` + +### Task 10: Move Discovery to pkg/discovery +- Copy mDNS implementation from `internal/discovery/mdns.go` + +### Task 11: Create High-Level Player API (pkg/resonate) +- Implement `Player` struct wrapping protocol client, decoder, scheduler, output +- Implement `PlayerConfig` and `NewPlayer()` +- Implement `Connect()`, `Play()`, `Pause()`, `Stop()`, `SetVolume()`, `Mute()` +- Write integration tests + +### Task 12: Create High-Level Server API (pkg/resonate) +- Implement `Server` struct wrapping audio engine and WebSocket server +- Implement `ServerConfig` and `NewServer()` +- Implement `Start()`, `Stop()`, `Clients()` +- Implement `AudioSource` interface +- Implement `FileSource()` and `TestToneSource()` +- Write integration tests + +### Task 13: Migrate Player CLI +- Rewrite `cmd/resonate-player/main.go` to use `pkg/resonate.Player` +- Keep TUI in `internal/ui/` +- Verify CLI works identically + +### Task 14: Migrate Server CLI +- Rewrite `cmd/resonate-server/main.go` to use `pkg/resonate.Server` +- Keep TUI in `internal/server/tui.go` (move to `internal/ui/`) +- Verify CLI works identically + +### Task 15: Add Examples +- Create `examples/basic-player/` +- Create `examples/basic-server/` +- Create `examples/custom-source/` +- Create `examples/multi-room/` +- Create `examples/audio-pipeline/` + +### Task 16: Update Documentation +- Update README.md with library usage +- Add godoc comments to all exported types/functions +- Update examples in README + +### Task 17: Final Testing and Release +- Run all tests: `go test ./...` +- Run CLI tools and verify functionality +- Run all examples +- Update CHANGELOG +- Tag v1.0.0 + +--- + +## Success Criteria + +- ✅ All code moved from `internal/` to appropriate `pkg/` packages +- ✅ High-level Player API works for simple use cases +- ✅ High-level Server API works for simple use cases +- ✅ Low-level component APIs work independently +- ✅ CLI tools work using public library APIs +- ✅ All examples run successfully +- ✅ All tests pass +- ✅ Documentation complete (godoc + README + examples) +- ✅ Tagged as v1.0.0 diff --git a/third_party/sendspin-go/docs/plans/phase1-hires-fixes.md b/third_party/sendspin-go/docs/plans/phase1-hires-fixes.md new file mode 100644 index 0000000..6aa3430 --- /dev/null +++ b/third_party/sendspin-go/docs/plans/phase1-hires-fixes.md @@ -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 diff --git a/third_party/sendspin-go/docs/superpowers/plans/2026-04-12-layered-architecture.md b/third_party/sendspin-go/docs/superpowers/plans/2026-04-12-layered-architecture.md new file mode 100644 index 0000000..abb0878 --- /dev/null +++ b/third_party/sendspin-go/docs/superpowers/plans/2026-04-12-layered-architecture.md @@ -0,0 +1,1464 @@ +# Layered Architecture 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:** Split `pkg/sendspin.Player` into a composable `Receiver` (connect/sync/decode/schedule) and a thin `Player` wrapper (output/volume/callback), enabling library consumers to get decoded audio without playback. + +**Architecture:** Extract all connection, sync, decode, and scheduling goroutines from `player.go` into a new `receiver.go`. The `Receiver` type emits decoded `audio.Buffer` via a channel. `Player` becomes a thin wrapper that composes `Receiver` + `output.Output` + optional `ProcessCallback`. No breaking changes to the existing API. + +**Tech Stack:** Go 1.24+, existing `pkg/protocol`, `pkg/sync`, `pkg/audio/decode`, `pkg/audio/output` packages. + +**Spec:** `docs/2026-04-12-layered-architecture-design.md` + +--- + +## File Structure + +| File | Action | Responsibility | +|------|--------|----------------| +| `pkg/sendspin/receiver.go` | CREATE | Receiver type, ReceiverConfig, all connection/decode/sync/schedule goroutines | +| `pkg/sendspin/receiver_test.go` | CREATE | Receiver unit tests | +| `pkg/sendspin/player.go` | MODIFY | Slim down to compose Receiver + Output + ProcessCallback | +| `pkg/sendspin/player_test.go` | CREATE | Player integration tests with mock output | +| `pkg/sync/clock.go` | MODIFY | Add deprecation warnings to global functions | +| `pkg/sendspin/scheduler.go` | MODIFY | Replace `sync.ServerMicrosNow()` with injected ClockSync method | + +--- + +### Task 1: Add ServerMicrosNow Method to ClockSync + +The scheduler currently calls the global `sync.ServerMicrosNow()`. Before moving code into Receiver, we need a non-global equivalent on the `ClockSync` instance so the Receiver's scheduler can use it without the global. + +**Files:** +- Modify: `pkg/sync/clock.go` +- Test: `pkg/sync/clock_test.go` + +- [ ] **Step 1: Write the failing test** + +Add to `pkg/sync/clock_test.go`: + +```go +func TestClockSync_ServerMicrosNow(t *testing.T) { + cs := NewClockSync() + + // Before sync, should return roughly current Unix micros + now1 := cs.ServerMicrosNow() + unixNow := time.Now().UnixMicro() + if abs64(now1-unixNow) > 1000000 { // within 1 second + t.Errorf("before sync: expected ~%d, got %d", unixNow, now1) + } + + // After sync, should return server-frame time + cs.ProcessSyncResponse(1000, 500000, 500100, 1200) + now2 := cs.ServerMicrosNow() + if now2 == 0 { + t.Error("after sync: got zero") + } +} + +func abs64(x int64) int64 { + if x < 0 { + return -x + } + return x +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./pkg/sync/ -run TestClockSync_ServerMicrosNow -v` +Expected: FAIL — `cs.ServerMicrosNow undefined` + +- [ ] **Step 3: Write the implementation** + +Add to `pkg/sync/clock.go` after the existing `ServerToLocalTime` method: + +```go +// ServerMicrosNow returns current time in server's reference frame (us). +// This is the instance method equivalent of the deprecated package-level ServerMicrosNow(). +func (cs *ClockSync) ServerMicrosNow() int64 { + cs.mu.RLock() + defer cs.mu.RUnlock() + + if !cs.filter.Synced() { + return time.Now().UnixMicro() + } + + return cs.filter.ComputeServerTime(time.Now().UnixMicro()) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./pkg/sync/ -run TestClockSync_ServerMicrosNow -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add pkg/sync/clock.go pkg/sync/clock_test.go +git commit -m "add ServerMicrosNow instance method to ClockSync" +``` + +--- + +### Task 2: Update Scheduler to Use Injected ClockSync + +Replace the global `sync.ServerMicrosNow()` call in the scheduler with the injected `clockSync` instance method from Task 1. + +**Files:** +- Modify: `pkg/sendspin/scheduler.go` + +- [ ] **Step 1: Replace global call with instance method** + +In `pkg/sendspin/scheduler.go`, line 74, change: + +```go +serverNow := sync.ServerMicrosNow() +``` + +to: + +```go +serverNow := s.clockSync.ServerMicrosNow() +``` + +- [ ] **Step 2: Run existing tests** + +Run: `go test ./pkg/sendspin/ -v` +Expected: PASS (existing tests still work) + +- [ ] **Step 3: Commit** + +```bash +git add pkg/sendspin/scheduler.go +git commit -m "use injected ClockSync instance in scheduler instead of global" +``` + +--- + +### Task 3: Add Deprecation Warnings to Global Sync Functions + +Mark the package-level `SetGlobalClockSync` and `ServerMicrosNow` as deprecated. They still work but log a warning on first use. + +**Files:** +- Modify: `pkg/sync/clock.go` + +- [ ] **Step 1: Add deprecation warnings** + +In `pkg/sync/clock.go`, replace the existing global functions with: + +```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 +} + +// 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 + if cs == nil { + return time.Now().UnixMicro() + } + + cs.mu.RLock() + defer cs.mu.RUnlock() + + if !cs.filter.Synced() { + return time.Now().UnixMicro() + } + + return cs.filter.ComputeServerTime(time.Now().UnixMicro()) +} +``` + +- [ ] **Step 2: Run existing tests** + +Run: `go test ./pkg/sync/ -v` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add pkg/sync/clock.go +git commit -m "deprecate global SetGlobalClockSync and ServerMicrosNow functions" +``` + +--- + +### Task 4: Create Receiver Type and Config + +Create the `Receiver` type with its config struct and constructor. No goroutines yet — just the type, constructor, and stub methods. + +**Files:** +- Create: `pkg/sendspin/receiver.go` +- Create: `pkg/sendspin/receiver_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `pkg/sendspin/receiver_test.go`: + +```go +// ABOUTME: Tests for the Receiver type +// ABOUTME: Verifies Receiver creation, config defaults, and lifecycle +package sendspin + +import ( + "testing" +) + +func TestNewReceiver_Defaults(t *testing.T) { + recv, err := NewReceiver(ReceiverConfig{ + ServerAddr: "localhost:8927", + PlayerName: "Test Receiver", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if recv == nil { + t.Fatal("expected non-nil receiver") + } + if recv.clockSync == nil { + t.Error("expected clockSync to be initialized") + } + if recv.Output() == nil { + t.Error("expected output channel to be non-nil") + } +} + +func TestNewReceiver_RequiresServerAddr(t *testing.T) { + _, err := NewReceiver(ReceiverConfig{ + PlayerName: "Test", + }) + if err == nil { + t.Fatal("expected error for missing ServerAddr") + } +} + +func TestReceiver_ClockSyncIsOwnInstance(t *testing.T) { + recv1, _ := NewReceiver(ReceiverConfig{ + ServerAddr: "localhost:8927", + PlayerName: "Receiver 1", + }) + recv2, _ := NewReceiver(ReceiverConfig{ + ServerAddr: "localhost:8928", + PlayerName: "Receiver 2", + }) + + if recv1.ClockSync() == recv2.ClockSync() { + t.Error("expected each receiver to have its own ClockSync instance") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./pkg/sendspin/ -run TestNewReceiver -v` +Expected: FAIL — `NewReceiver` undefined + +- [ ] **Step 3: Write the Receiver type and constructor** + +Create `pkg/sendspin/receiver.go`: + +```go +// ABOUTME: Receiver handles connection, sync, decode, and scheduling +// ABOUTME: Emits decoded audio.Buffer via Output() channel for consumers +package sendspin + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/Sendspin/sendspin-go/pkg/audio" + "github.com/Sendspin/sendspin-go/pkg/audio/decode" + "github.com/Sendspin/sendspin-go/pkg/protocol" + "github.com/Sendspin/sendspin-go/pkg/sync" + "github.com/google/uuid" +) + +// ReceiverConfig configures a Receiver +type ReceiverConfig struct { + // ServerAddr is the server address (host:port) + ServerAddr string + + // PlayerName is the display name for this client + PlayerName string + + // BufferMs is the playback buffer size in milliseconds (default: 500) + BufferMs int + + // DeviceInfo provides device identification + DeviceInfo DeviceInfo + + // DecoderFactory overrides the default decoder selection. + // When nil, the default codec switch (PCM, Opus, FLAC) is used. + DecoderFactory func(audio.Format) (decode.Decoder, error) + + // OnMetadata is called when metadata is received + OnMetadata func(Metadata) + + // OnStreamStart is called when a stream starts with its format + OnStreamStart func(audio.Format) + + // OnStreamEnd is called when the stream ends + OnStreamEnd func() + + // OnError is called when errors occur + OnError func(error) +} + +// ReceiverStats contains receiver pipeline statistics +type ReceiverStats struct { + Received int64 + Played int64 + Dropped int64 + BufferDepth int + SyncRTT int64 + SyncQuality sync.Quality +} + +// Receiver handles connection, clock sync, decoding, and scheduling. +// It emits decoded, time-stamped audio buffers via the Output() channel. +type Receiver struct { + config ReceiverConfig + + // Components + client *protocol.Client + clockSync *sync.ClockSync + scheduler *Scheduler + decoder decode.Decoder + format audio.Format + + // Output channel for decoded buffers + output chan audio.Buffer + + // Lifecycle + ctx context.Context + cancel context.CancelFunc + schedulerCtx context.Context + schedulerCancel context.CancelFunc + serverAddr string + connected bool +} + +// NewReceiver creates a new Receiver with the given configuration +func NewReceiver(config ReceiverConfig) (*Receiver, error) { + if config.ServerAddr == "" { + return nil, fmt.Errorf("ServerAddr is required") + } + + if config.BufferMs == 0 { + config.BufferMs = 500 + } + if config.DeviceInfo.ProductName == "" { + config.DeviceInfo.ProductName = "Sendspin Player" + } + if config.DeviceInfo.Manufacturer == "" { + config.DeviceInfo.Manufacturer = "Sendspin" + } + if config.DeviceInfo.SoftwareVersion == "" { + config.DeviceInfo.SoftwareVersion = "1.0.0" + } + + ctx, cancel := context.WithCancel(context.Background()) + + return &Receiver{ + config: config, + clockSync: sync.NewClockSync(), + output: make(chan audio.Buffer, 10), + ctx: ctx, + cancel: cancel, + serverAddr: config.ServerAddr, + }, nil +} + +// Output returns the channel of decoded, scheduled audio buffers. +// The channel is closed when Close() is called. +func (r *Receiver) Output() <-chan audio.Buffer { + return r.output +} + +// ClockSync returns the clock synchronization instance for this Receiver. +func (r *Receiver) ClockSync() *sync.ClockSync { + return r.clockSync +} + +// Stats returns pipeline statistics +func (r *Receiver) Stats() ReceiverStats { + stats := ReceiverStats{} + + if r.scheduler != nil { + s := r.scheduler.Stats() + stats.Received = s.Received + stats.Played = s.Played + stats.Dropped = s.Dropped + stats.BufferDepth = r.scheduler.BufferDepth() + } + + if r.clockSync != nil { + rtt, quality := r.clockSync.GetStats() + stats.SyncRTT = rtt + stats.SyncQuality = quality + } + + return stats +} + +// Close tears down the Receiver and closes the Output channel. +func (r *Receiver) Close() error { + r.cancel() + + if r.client != nil { + r.client.SendGoodbye("shutdown") + r.client.Close() + } + + if r.schedulerCancel != nil { + r.schedulerCancel() + } + if r.scheduler != nil { + r.scheduler.Stop() + } + + if r.decoder != nil { + r.decoder.Close() + } + + close(r.output) + r.connected = false + + return nil +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./pkg/sendspin/ -run TestNewReceiver -v && go test ./pkg/sendspin/ -run TestReceiver -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add pkg/sendspin/receiver.go pkg/sendspin/receiver_test.go +git commit -m "add Receiver type with config, constructor, and lifecycle methods" +``` + +--- + +### Task 5: Move Connection and Sync Goroutines to Receiver + +Move `Connect`, `performInitialSync`, `clockSyncLoop`, and `watchConnection` from `player.go` into `receiver.go`. + +**Files:** +- Modify: `pkg/sendspin/receiver.go` +- Modify: `pkg/sendspin/receiver_test.go` + +- [ ] **Step 1: Write a test for Connect returning error on bad address** + +Add to `pkg/sendspin/receiver_test.go`: + +```go +func TestReceiver_Connect_BadAddress(t *testing.T) { + recv, _ := NewReceiver(ReceiverConfig{ + ServerAddr: "localhost:99999", + PlayerName: "Test", + }) + + err := recv.Connect() + if err == nil { + t.Fatal("expected connection error for bad address") + } +} +``` + +- [ ] **Step 2: Add Connect method to Receiver** + +Add to `pkg/sendspin/receiver.go`: + +```go +// Connect establishes connection to the server, performs initial clock sync, +// and starts all pipeline goroutines. +func (r *Receiver) Connect() error { + clientID := uuid.New().String() + + clientConfig := protocol.Config{ + ServerAddr: r.serverAddr, + ClientID: clientID, + Name: r.config.PlayerName, + Version: 1, + DeviceInfo: protocol.DeviceInfo{ + ProductName: r.config.DeviceInfo.ProductName, + Manufacturer: r.config.DeviceInfo.Manufacturer, + SoftwareVersion: r.config.DeviceInfo.SoftwareVersion, + }, + PlayerV1Support: protocol.PlayerV1Support{ + SupportedFormats: []protocol.AudioFormat{ + {Codec: "pcm", Channels: 2, SampleRate: 192000, BitDepth: 24}, + {Codec: "pcm", Channels: 2, SampleRate: 176400, BitDepth: 24}, + {Codec: "pcm", Channels: 2, SampleRate: 96000, BitDepth: 24}, + {Codec: "pcm", Channels: 2, SampleRate: 88200, BitDepth: 24}, + {Codec: "pcm", Channels: 2, SampleRate: 48000, BitDepth: 16}, + {Codec: "pcm", Channels: 2, SampleRate: 44100, BitDepth: 16}, + {Codec: "opus", Channels: 2, SampleRate: 48000, BitDepth: 16}, + }, + BufferCapacity: 1048576, + SupportedCommands: []string{"volume", "mute"}, + }, + ArtworkV1Support: &protocol.ArtworkV1Support{ + Channels: []protocol.ArtworkChannel{ + {Source: "album", Format: "jpeg", MediaWidth: 600, MediaHeight: 600}, + }, + }, + VisualizerV1Support: &protocol.VisualizerV1Support{ + BufferCapacity: 1048576, + }, + } + + r.client = protocol.NewClient(clientConfig) + + if err := r.client.Connect(); err != nil { + return fmt.Errorf("connection failed: %w", err) + } + + log.Printf("Connected to server: %s", r.serverAddr) + r.connected = true + + if err := r.performInitialSync(); err != nil { + log.Printf("Initial clock sync failed: %v", err) + } + + go r.watchConnection() + go r.handleStreamStart() + go r.handleStreamClear() + go r.handleStreamEnd() + go r.handleAudioChunks() + go r.handleServerState() + go r.handleGroupUpdates() + go r.clockSyncLoop() + + return nil +} + +func (r *Receiver) watchConnection() { + select { + case <-r.client.Done(): + log.Printf("Server connection lost, shutting down receiver") + r.connected = false + r.notifyError(fmt.Errorf("server connection lost")) + r.cancel() + case <-r.ctx.Done(): + return + } +} + +func (r *Receiver) performInitialSync() error { + log.Printf("Performing initial clock synchronization...") + + for i := 0; i < 5; i++ { + t1 := time.Now().UnixMicro() + r.client.SendTimeSync(t1) + + select { + case resp := <-r.client.TimeSyncResp: + t4 := time.Now().UnixMicro() + r.clockSync.ProcessSyncResponse(resp.ClientTransmitted, resp.ServerReceived, resp.ServerTransmitted, t4) + case <-time.After(500 * time.Millisecond): + log.Printf("Initial sync round %d timeout", i+1) + } + + time.Sleep(100 * time.Millisecond) + } + + rtt, quality := r.clockSync.GetStats() + log.Printf("Initial clock sync complete: rtt=%dus, quality=%v", rtt, quality) + + return nil +} + +func (r *Receiver) clockSyncLoop() { + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + for { + select { + case <-r.client.TimeSyncResp: + log.Printf("Discarded stale time sync response") + default: + goto sendRequest + } + } + + sendRequest: + t1 := time.Now().UnixMicro() + r.client.SendTimeSync(t1) + + case resp := <-r.client.TimeSyncResp: + t4 := time.Now().UnixMicro() + r.clockSync.ProcessSyncResponse(resp.ClientTransmitted, resp.ServerReceived, resp.ServerTransmitted, t4) + + case <-r.ctx.Done(): + return + } + } +} + +func (r *Receiver) notifyError(err error) { + if r.config.OnError != nil { + r.config.OnError(err) + } else { + log.Printf("Receiver error: %v", err) + } +} +``` + +- [ ] **Step 3: Run tests** + +Run: `go test ./pkg/sendspin/ -run TestReceiver -v` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add pkg/sendspin/receiver.go pkg/sendspin/receiver_test.go +git commit -m "add Connect, clock sync, and connection watch to Receiver" +``` + +--- + +### Task 6: Move Stream and Audio Goroutines to Receiver + +Move `handleStreamStart`, `handleStreamClear`, `handleStreamEnd`, `handleAudioChunks`, `handleServerState`, and `handleGroupUpdates` from `player.go` into `receiver.go`. The key change: instead of writing to `output.Write()`, `handleScheduledAudio` now writes to `r.output` channel. + +**Files:** +- Modify: `pkg/sendspin/receiver.go` + +- [ ] **Step 1: Add stream and audio handling methods to Receiver** + +Add to `pkg/sendspin/receiver.go`: + +```go +func (r *Receiver) handleStreamStart() { + for { + select { + case start := <-r.client.StreamStart: + if start.Player == nil { + log.Printf("Received stream/start with no player info") + continue + } + + log.Printf("Stream starting: %s %dHz %dch %dbit", + start.Player.Codec, start.Player.SampleRate, start.Player.Channels, start.Player.BitDepth) + + format := audio.Format{ + Codec: start.Player.Codec, + SampleRate: start.Player.SampleRate, + Channels: start.Player.Channels, + BitDepth: start.Player.BitDepth, + } + + // Create decoder + var decoder decode.Decoder + var err error + + if r.config.DecoderFactory != nil { + decoder, err = r.config.DecoderFactory(format) + } else { + decoder, err = r.defaultDecoder(format) + } + + if err != nil { + r.notifyError(fmt.Errorf("failed to create decoder: %w", err)) + continue + } + r.decoder = decoder + r.format = format + + // Notify consumer of stream start + if r.config.OnStreamStart != nil { + r.config.OnStreamStart(format) + } + + // Stop any existing scheduler goroutines + if r.schedulerCancel != nil { + r.schedulerCancel() + } + if r.scheduler != nil { + r.scheduler.Stop() + } + + r.schedulerCtx, r.schedulerCancel = context.WithCancel(r.ctx) + r.scheduler = NewScheduler(r.clockSync, r.config.BufferMs) + go r.scheduler.Run() + go r.pumpSchedulerOutput(r.schedulerCtx) + + case <-r.ctx.Done(): + return + } + } +} + +func (r *Receiver) defaultDecoder(format audio.Format) (decode.Decoder, error) { + switch format.Codec { + case "pcm": + return decode.NewPCM(format) + case "opus": + return decode.NewOpus(format) + case "flac": + return decode.NewFLAC(format) + default: + return nil, fmt.Errorf("unsupported codec: %s", format.Codec) + } +} + +func (r *Receiver) handleAudioChunks() { + for { + select { + case chunk := <-r.client.AudioChunks: + if r.decoder == nil || r.scheduler == nil { + continue + } + + pcm, err := r.decoder.Decode(chunk.Data) + if err != nil { + r.notifyError(fmt.Errorf("decode error: %w", err)) + continue + } + + buf := audio.Buffer{ + Timestamp: chunk.Timestamp, + Samples: pcm, + Format: r.format, + } + r.scheduler.Schedule(buf) + + case <-r.ctx.Done(): + return + } + } +} + +// pumpSchedulerOutput reads from the scheduler and forwards to the output channel +func (r *Receiver) pumpSchedulerOutput(ctx context.Context) { + for { + select { + case buf := <-r.scheduler.Output(): + select { + case r.output <- buf: + case <-ctx.Done(): + return + } + case <-ctx.Done(): + return + } + } +} + +func (r *Receiver) handleStreamClear() { + for { + select { + case clear := <-r.client.StreamClear: + log.Printf("Stream clear received for roles: %v", clear.Roles) + if len(clear.Roles) == 0 || containsRole(clear.Roles, "player") { + if r.scheduler != nil { + r.scheduler.Clear() + } + } + case <-r.ctx.Done(): + return + } + } +} + +func (r *Receiver) handleStreamEnd() { + for { + select { + case end := <-r.client.StreamEnd: + log.Printf("Stream end received for roles: %v", end.Roles) + if len(end.Roles) == 0 || containsRole(end.Roles, "player") { + if r.config.OnStreamEnd != nil { + r.config.OnStreamEnd() + } + } + case <-r.ctx.Done(): + return + } + } +} + +func (r *Receiver) handleServerState() { + for { + select { + case state := <-r.client.ServerState: + if state.Metadata != nil && r.config.OnMetadata != nil { + meta := state.Metadata + r.config.OnMetadata(Metadata{ + Title: derefString(meta.Title), + Artist: derefString(meta.Artist), + Album: derefString(meta.Album), + AlbumArtist: derefString(meta.AlbumArtist), + ArtworkURL: derefString(meta.ArtworkURL), + Track: derefInt(meta.Track), + Year: derefInt(meta.Year), + Duration: getDurationSeconds(meta.Progress), + }) + } + case <-r.ctx.Done(): + return + } + } +} + +func (r *Receiver) handleGroupUpdates() { + for { + select { + case update := <-r.client.GroupUpdate: + if update.PlaybackState != nil { + log.Printf("Group playback state: %s", *update.PlaybackState) + } + if update.GroupID != nil { + log.Printf("Joined group: %s", *update.GroupID) + } + case <-r.ctx.Done(): + return + } + } +} +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `go build ./pkg/sendspin/` +Expected: No errors + +- [ ] **Step 3: Commit** + +```bash +git add pkg/sendspin/receiver.go +git commit -m "move stream handling and audio pipeline goroutines to Receiver" +``` + +--- + +### Task 7: Refactor Player to Compose Receiver + +Rewrite `player.go` to compose a `Receiver` internally. Remove all the goroutines that moved to `receiver.go`. Add `ProcessCallback` support. Preserve the entire existing public API. + +**Files:** +- Modify: `pkg/sendspin/player.go` +- Create: `pkg/sendspin/player_test.go` + +- [ ] **Step 1: Write tests for the refactored Player** + +Create `pkg/sendspin/player_test.go`: + +```go +// ABOUTME: Tests for refactored Player composing Receiver +// ABOUTME: Verifies backward-compatible API and new ProcessCallback +package sendspin + +import ( + "testing" +) + +func TestNewPlayer_Defaults(t *testing.T) { + player, err := NewPlayer(PlayerConfig{ + ServerAddr: "localhost:8927", + PlayerName: "Test Player", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if player == nil { + t.Fatal("expected non-nil player") + } + if player.receiver != nil { + t.Error("receiver should be nil before Connect") + } +} + +func TestNewPlayer_ProcessCallbackStored(t *testing.T) { + called := false + player, _ := NewPlayer(PlayerConfig{ + ServerAddr: "localhost:8927", + PlayerName: "Test Player", + ProcessCallback: func(samples []int32) { + called = true + }, + }) + if player.config.ProcessCallback == nil { + t.Error("expected ProcessCallback to be stored in config") + } +} + +func TestPlayer_StatusBeforeConnect(t *testing.T) { + player, _ := NewPlayer(PlayerConfig{ + ServerAddr: "localhost:8927", + PlayerName: "Test Player", + Volume: 80, + }) + + status := player.Status() + if status.Volume != 80 { + t.Errorf("expected volume 80, got %d", status.Volume) + } + if status.Connected { + t.Error("expected not connected before Connect()") + } + if status.State != "idle" { + t.Errorf("expected state idle, got %s", status.State) + } +} +``` + +- [ ] **Step 2: Rewrite player.go** + +Replace the contents of `pkg/sendspin/player.go` with: + +```go +// ABOUTME: High-level Player API for Sendspin streaming +// ABOUTME: Composes Receiver + audio output with optional ProcessCallback +package sendspin + +import ( + "context" + "fmt" + "log" + + "github.com/Sendspin/sendspin-go/pkg/audio" + "github.com/Sendspin/sendspin-go/pkg/audio/output" + "github.com/Sendspin/sendspin-go/pkg/protocol" + "github.com/Sendspin/sendspin-go/pkg/sync" +) + +// PlayerConfig holds player configuration +type PlayerConfig struct { + // ServerAddr is the server address (host:port) + ServerAddr string + + // PlayerName is the display name for this player + PlayerName string + + // Volume is the initial volume (0-100) + Volume int + + // BufferMs is the playback buffer size in milliseconds (default: 500) + BufferMs int + + // DeviceInfo provides device identification + DeviceInfo DeviceInfo + + // OnMetadata is called when metadata is received + OnMetadata func(Metadata) + + // OnStateChange is called when playback state changes + OnStateChange func(PlayerState) + + // OnError is called when errors occur + OnError func(error) + + // 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 + + // DecoderFactory overrides the default decoder selection. + // When nil, the default codec switch (PCM, Opus, FLAC) is used. + DecoderFactory func(audio.Format) (decode.Decoder, error) + + // ProcessCallback is called with decoded samples before they are written to output. + // Must not block. Runs on the audio consumption goroutine. + ProcessCallback func([]int32) +} + +// Player provides high-level audio playback from Sendspin servers. +// It composes a Receiver (connect/sync/decode/schedule) with an audio output backend. +type Player struct { + config PlayerConfig + receiver *Receiver + output output.Output + state PlayerState + ctx context.Context + cancel context.CancelFunc +} + +// NewPlayer creates a new player with the given configuration +func NewPlayer(config PlayerConfig) (*Player, error) { + if config.Volume == 0 { + config.Volume = 100 + } + if config.BufferMs == 0 { + config.BufferMs = 500 + } + + ctx, cancel := context.WithCancel(context.Background()) + + return &Player{ + config: config, + output: config.Output, // may be nil — auto-selected on stream start + ctx: ctx, + cancel: cancel, + state: PlayerState{ + State: "idle", + Volume: config.Volume, + Muted: false, + Connected: false, + }, + }, nil +} + +// Connect establishes connection to the server and starts playback +func (p *Player) Connect() error { + recv, err := NewReceiver(ReceiverConfig{ + ServerAddr: p.config.ServerAddr, + PlayerName: p.config.PlayerName, + BufferMs: p.config.BufferMs, + DeviceInfo: p.config.DeviceInfo, + DecoderFactory: p.config.DecoderFactory, + OnMetadata: p.config.OnMetadata, + OnStreamStart: p.onStreamStart, + OnStreamEnd: p.onStreamEnd, + OnError: p.config.OnError, + }) + if err != nil { + return err + } + + p.receiver = recv + + if err := recv.Connect(); err != nil { + return err + } + + // Backward compat: set global clock sync + sync.SetGlobalClockSync(recv.ClockSync()) + + p.state.Connected = true + p.notifyStateChange() + + // Start consuming decoded buffers + go p.consumeAudio() + + return nil +} + +func (p *Player) onStreamStart(format audio.Format) { + // Auto-select output if not provided + 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) + } + } + + if err := p.output.Open(format.SampleRate, format.Channels, format.BitDepth); err != nil { + p.notifyError(fmt.Errorf("failed to initialize output: %w", err)) + return + } + + // Apply current volume + p.output.SetVolume(p.state.Volume) + p.output.SetMuted(p.state.Muted) + + p.state.Codec = format.Codec + p.state.SampleRate = format.SampleRate + p.state.Channels = format.Channels + p.state.BitDepth = format.BitDepth + p.state.State = "playing" + p.notifyStateChange() +} + +func (p *Player) onStreamEnd() { + p.state.State = "idle" + p.notifyStateChange() +} + +func (p *Player) consumeAudio() { + for { + select { + case buf, ok := <-p.receiver.Output(): + if !ok { + return // channel closed + } + + if p.config.ProcessCallback != nil { + p.config.ProcessCallback(buf.Samples) + } + + if p.output != nil { + if err := p.output.Write(buf.Samples); err != nil { + p.notifyError(fmt.Errorf("playback error: %w", err)) + } + } + + case <-p.ctx.Done(): + return + } + } +} + +// Play starts or resumes playback +func (p *Player) Play() error { + if !p.state.Connected { + return fmt.Errorf("not connected") + } + p.state.State = "playing" + p.notifyStateChange() + return p.sendState() +} + +// Pause pauses playback +func (p *Player) Pause() error { + if !p.state.Connected { + return fmt.Errorf("not connected") + } + p.state.State = "paused" + p.notifyStateChange() + return p.sendState() +} + +// Stop stops playback +func (p *Player) Stop() error { + if !p.state.Connected { + return fmt.Errorf("not connected") + } + p.state.State = "idle" + p.notifyStateChange() + return p.sendState() +} + +// SetVolume sets the volume (0-100) +func (p *Player) SetVolume(volume int) error { + if volume < 0 { + volume = 0 + } + if volume > 100 { + volume = 100 + } + p.state.Volume = volume + + if p.output != nil { + p.output.SetVolume(volume) + } + + if p.receiver != nil && p.state.Connected { + p.sendState() + } + + p.notifyStateChange() + return nil +} + +// Mute sets the mute state +func (p *Player) Mute(muted bool) error { + p.state.Muted = muted + + if p.output != nil { + p.output.SetMuted(muted) + } + + if p.receiver != nil && p.state.Connected { + p.sendState() + } + + p.notifyStateChange() + return nil +} + +// Status returns the current player state +func (p *Player) Status() PlayerState { + return p.state +} + +// Stats returns playback statistics +func (p *Player) Stats() PlayerStats { + stats := PlayerStats{} + + if p.receiver != nil { + rs := p.receiver.Stats() + stats.Received = rs.Received + stats.Played = rs.Played + stats.Dropped = rs.Dropped + stats.BufferDepth = rs.BufferDepth + stats.SyncRTT = rs.SyncRTT + stats.SyncQuality = rs.SyncQuality + } + + return stats +} + +// Close closes the player and releases all resources +func (p *Player) Close() error { + p.cancel() + + if p.receiver != nil { + p.receiver.Close() + } + + if p.output != nil { + p.output.Close() + } + + p.state.Connected = false + p.state.State = "idle" + p.notifyStateChange() + + return nil +} + +func (p *Player) sendState() error { + if p.receiver == nil || p.receiver.client == nil { + return nil + } + return p.receiver.client.SendState(protocol.PlayerState{ + State: "synchronized", + Volume: p.state.Volume, + Muted: p.state.Muted, + }) +} + +func (p *Player) notifyStateChange() { + if p.config.OnStateChange != nil { + p.config.OnStateChange(p.state) + } +} + +func (p *Player) notifyError(err error) { + if p.config.OnError != nil { + p.config.OnError(err) + } else { + log.Printf("Player error: %v", err) + } +} +``` + +- [ ] **Step 3: Add missing import for decode package** + +The `PlayerConfig.DecoderFactory` references `decode.Decoder`. Add this import alias at the top of `player.go`: + +```go +import ( + // ... existing imports + "github.com/Sendspin/sendspin-go/pkg/audio/decode" +) +``` + +Note: The `decode` import is needed for the `DecoderFactory` field type in `PlayerConfig`. If Go complains about an unused import (because `decode.Decoder` is only in the function signature type), use a blank import or reference it in a type alias. In practice, `func(audio.Format) (decode.Decoder, error)` will require the import. + +- [ ] **Step 4: Run all tests** + +Run: `go test ./pkg/sendspin/ -v` +Expected: PASS — new Player tests pass, existing code compiles + +- [ ] **Step 5: Commit** + +```bash +git add pkg/sendspin/player.go pkg/sendspin/player_test.go +git commit -m "refactor Player to compose Receiver internally + +Player now delegates connection, sync, decode, and scheduling to +Receiver. Adds Output, DecoderFactory, and ProcessCallback config +fields. Existing API fully preserved." +``` + +--- + +### Task 8: Remove Dead Code from player.go + +After the refactor, verify there is no leftover dead code from the original player.go (old goroutine methods, duplicate helper functions, unused imports). + +**Files:** +- Modify: `pkg/sendspin/player.go` + +- [ ] **Step 1: Check for duplicate helper functions** + +The helper functions `derefString`, `derefInt`, `getDurationSeconds`, `containsRole` are used by receiver.go now. They should exist in exactly one file. If they're defined in both `player.go` and `receiver.go`, remove them from `player.go` since the receiver uses them. + +Check: `grep -n "func derefString\|func derefInt\|func getDurationSeconds\|func containsRole" pkg/sendspin/*.go` + +Remove any duplicates from `player.go`. + +- [ ] **Step 2: Check for unused imports** + +Run: `go build ./pkg/sendspin/` +Expected: No errors. If there are unused import errors, remove them. + +Common removals from `player.go`: +- `"time"` (no longer used — sync loops moved to receiver) +- `"github.com/google/uuid"` (moved to receiver) +- `"github.com/Sendspin/sendspin-go/pkg/sync"` (only needed if `SetGlobalClockSync` is called — it is, so keep it) + +- [ ] **Step 3: Run full test suite** + +Run: `go test ./pkg/... -v` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add pkg/sendspin/player.go +git commit -m "remove dead code from player.go after Receiver extraction" +``` + +--- + +### Task 9: Update main.go CLI Entry Points + +Update the root `main.go` (player CLI) to verify it still works with the refactored Player. No behavioral changes expected — this is a verification task. + +**Files:** +- Review: `main.go` + +- [ ] **Step 1: Verify main.go compiles** + +Run: `go build -o sendspin-player .` +Expected: No errors + +- [ ] **Step 2: Verify server CLI compiles** + +Run: `go build -o sendspin-server ./cmd/sendspin-server/` +Expected: No errors + +- [ ] **Step 3: Verify all packages compile** + +Run: `go build ./...` +Expected: No errors + +- [ ] **Step 4: Run full test suite** + +Run: `go test ./... 2>&1 | tail -30` +Expected: All tests pass (some packages may skip if they need hardware) + +- [ ] **Step 5: Commit (only if changes were needed)** + +```bash +git add -A +git commit -m "fix any compilation issues in CLI entry points after refactor" +``` + +--- + +### Task 10: Integration Smoke Test + +Verify the refactored Player and new Receiver work end-to-end by adding an integration test that exercises the Receiver independently. + +**Files:** +- Modify: `pkg/sendspin/receiver_test.go` + +- [ ] **Step 1: Add Receiver lifecycle test** + +Add to `pkg/sendspin/receiver_test.go`: + +```go +func TestReceiver_CloseBeforeConnect(t *testing.T) { + recv, _ := NewReceiver(ReceiverConfig{ + ServerAddr: "localhost:8927", + PlayerName: "Test", + }) + + // Should not panic + err := recv.Close() + if err != nil { + t.Fatalf("unexpected error closing unconnected receiver: %v", err) + } +} + +func TestReceiver_StatsBeforeConnect(t *testing.T) { + recv, _ := NewReceiver(ReceiverConfig{ + ServerAddr: "localhost:8927", + PlayerName: "Test", + }) + + stats := recv.Stats() + if stats.Received != 0 || stats.Played != 0 || stats.Dropped != 0 { + t.Error("expected zero stats before connect") + } +} + +func TestReceiver_OnStreamStartCallback(t *testing.T) { + var receivedFormat audio.Format + _, err := NewReceiver(ReceiverConfig{ + ServerAddr: "localhost:8927", + PlayerName: "Test", + OnStreamStart: func(f audio.Format) { + receivedFormat = f + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Callback is stored but not invoked until stream starts + if receivedFormat.Codec != "" { + t.Error("expected empty format before stream start") + } +} + +func TestReceiver_CustomDecoderFactory(t *testing.T) { + factoryCalled := false + recv, _ := NewReceiver(ReceiverConfig{ + ServerAddr: "localhost:8927", + PlayerName: "Test", + DecoderFactory: func(f audio.Format) (decode.Decoder, error) { + factoryCalled = true + return nil, fmt.Errorf("test decoder") + }, + }) + + if recv.config.DecoderFactory == nil { + t.Error("expected DecoderFactory to be stored") + } + // Factory is stored but not invoked until stream starts + if factoryCalled { + t.Error("factory should not be called before connect") + } +} +``` + +- [ ] **Step 2: Add missing imports to test file** + +Ensure `receiver_test.go` imports: + +```go +import ( + "fmt" + "testing" + + "github.com/Sendspin/sendspin-go/pkg/audio" + "github.com/Sendspin/sendspin-go/pkg/audio/decode" +) +``` + +- [ ] **Step 3: Run all tests** + +Run: `go test ./pkg/sendspin/ -v` +Expected: PASS + +- [ ] **Step 4: Run full project test suite** + +Run: `go test ./...` +Expected: All packages pass + +- [ ] **Step 5: Commit and tag** + +```bash +git add pkg/sendspin/receiver_test.go +git commit -m "add integration tests for Receiver lifecycle and callbacks" +``` diff --git a/third_party/sendspin-go/docs/superpowers/plans/2026-04-14-drop-oto-backend.md b/third_party/sendspin-go/docs/superpowers/plans/2026-04-14-drop-oto-backend.md new file mode 100644 index 0000000..259b530 --- /dev/null +++ b/third_party/sendspin-go/docs/superpowers/plans/2026-04-14-drop-oto-backend.md @@ -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. diff --git a/third_party/sendspin-go/docs/superpowers/plans/2026-04-14-rip-legacy-cli-stack.md b/third_party/sendspin-go/docs/superpowers/plans/2026-04-14-rip-legacy-cli-stack.md new file mode 100644 index 0000000..86a8761 --- /dev/null +++ b/third_party/sendspin-go/docs/superpowers/plans/2026-04-14-rip-legacy-cli-stack.md @@ -0,0 +1,750 @@ +# Rip Legacy CLI Stack Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Delete `cmd/ma-player`, `cmd/test-sync`, and the entire `internal/` legacy player pipeline they depend on. Unify on the `pkg/sendspin.Player` + `sendspin-player` code path as the only player in the tree. + +**Architecture:** `cmd/ma-player` and `cmd/test-sync` both call `internal/app.Player`, which wires together `internal/client`, `internal/audio`, `internal/player` (scheduler), `internal/artwork`, and `internal/sync` into a parallel player pipeline that predates `pkg/sendspin`. Since PR #26 migrated `internal/app.Player`'s output layer to `pkg/audio/output.Malgo`, `ma-player` is functionally equivalent to `sendspin-player` — both negotiate the same supported-formats list, both route 24-bit hi-res through malgo, both connect to Music Assistant cleanly. Empirically verified: `sendspin-player.exe` connects to Music Assistant and plays 192kHz/24-bit successfully (log evidence in `sendspin-player.log` from v1.2.0 verification). This plan removes the duplicate pipeline and its supporting packages, then deletes the `pkg/sync.SetGlobalClockSync`/`ServerMicrosNow` deprecation shims that only existed to keep the legacy readers working. + +**Tech Stack:** Go 1.24+, existing `pkg/sendspin`, `pkg/sync`, `pkg/audio`, `pkg/protocol`, `pkg/discovery`. + +**Context:** +- Resolves issue #28 (sync deprecation migration). +- Partially addresses issue #35 (release workflow only builds `ma-player` + `sendspin-player` Linux binaries — after this, only `sendspin-player` remains to ship). +- Does NOT fix issue #27 (unknown binary type 8), #29 (memory stats), #30 (clock offset display), #31 (duplicate Resampler), #32 (hello log spam), #33 (resonate-artwork cache dir), #34 (FLAC stub) — those are separate and unrelated to this cleanup. +- The `docs/superpowers/plans/2026-04-12-layered-architecture.md` plan is scoped to `pkg/sendspin` and does not overlap with this work. + +**Out of scope:** +- Any refactor of `pkg/sendspin.Player` itself. +- Deletion of `internal/server/` — that package is still live and imported by `cmd/sendspin-server` and `pkg/sendspin/server.go`. +- Deletion of `internal/discovery/` — still imported by `internal/server`, `main.go`, and `pkg/sendspin`. +- Deletion of `internal/version/` — still imported by `main.go`. +- Deletion of `internal/ui/` — still imported by `main.go` for the TUI. + +--- + +## Dependency Audit Summary + +Reverse-import scan before writing this plan confirmed the following (all grep paths relative to repo root): + +| Package | Non-legacy importers (survive) | Legacy importers (die with `internal/app`) | +|---|---|---| +| `internal/app` | (none) | `cmd/ma-player/main.go`, `cmd/test-sync/main.go` | +| `internal/audio` | (none) | `internal/app/player.go`, `internal/player/scheduler.go` | +| `internal/client` | (none) | `internal/app/player.go` | +| `internal/player` | (none) | `internal/app/player.go` | +| `internal/artwork` | (none) | `internal/app/player.go` | +| `internal/protocol` | (none — already orphaned) | (none) | +| `internal/sync` | `main.go` (Quality enum only), `internal/ui/model.go`, `internal/ui/model_test.go` | `internal/app/player.go`, `internal/player/scheduler.go` | +| `internal/ui` | `main.go` | `internal/app/player.go` | +| `internal/version` | `main.go` | `internal/app/player.go` | +| `internal/discovery` | `internal/server/server.go`, `pkg/sendspin/{client_dialer,client_dialer_test,client_discovery_integration_test,server}.go`, `main.go` | `internal/app/player.go` | +| `internal/server` | `cmd/sendspin-server/main.go`, `pkg/sendspin/server.go` | (none) | + +**Key observation:** `pkg/sync.Quality` is byte-for-byte equivalent to `internal/sync.Quality` (same type, same three constants in the same iota order). `main.go`'s current `pkg/sync.Quality` → `internal/sync.Quality` conversion block (lines 207–216) is pure ceremony. Migrating the three callers of `internal/sync.Quality` (`internal/ui/model.go`, `internal/ui/model_test.go`, `main.go`) to `pkg/sync.Quality` allows `internal/sync/` to be deleted after `internal/app` and `internal/player` go. + +--- + +## File Structure + +**Delete entirely:** + +| Path | Lines | Notes | +|---|---|---| +| `cmd/ma-player/main.go` | ~90 | Duplicate of root `main.go` via legacy stack | +| `cmd/test-sync/main.go` | ~50 | Clock-sync debug harness, unused | +| `internal/app/player.go` | 567 | Legacy orchestrator | +| `internal/app/player_test.go` | 229 | | +| `internal/audio/decoder.go` | 125 | Duplicate of `pkg/audio/decode` | +| `internal/audio/decoder_test.go` | 64 | | +| `internal/audio/types.go` | 59 | | +| `internal/client/websocket.go` | 378 | Duplicate of `pkg/protocol.Client` | +| `internal/client/websocket_test.go` | 24 | | +| `internal/player/scheduler.go` | 238 | Duplicate of `pkg/sendspin.Scheduler` | +| `internal/player/scheduler_test.go` | 44 | | +| `internal/artwork/downloader.go` | 108 | Not used by `pkg/sendspin.Player` today | +| `internal/artwork/downloader_test.go` | 278 | | +| `internal/sync/clock.go` | 139 | Duplicate of `pkg/sync/clock.go` | +| `internal/sync/clock_test.go` | (varies) | | +| `internal/protocol/messages.go` | 130 | Already orphaned (no non-internal importers) | +| `internal/protocol/messages_test.go` | 79 | | + +**Modify:** + +| Path | Change | +|---|---| +| `internal/ui/model.go` | Replace `internal/sync` import with `pkg/sync`; type of `syncQuality` and `StatusMsg.SyncQuality` unchanged in name, changes in import source | +| `internal/ui/model_test.go` | Same replacement | +| `main.go` | Drop `internalsync "github.com/Sendspin/sendspin-go/internal/sync"` import; delete the Quality conversion block at ~lines 207–216; pass `stats.SyncQuality` directly to `ui.StatusMsg` | +| `pkg/sync/clock.go` | Delete package-level deprecated `SetGlobalClockSync`, `ServerMicrosNow`, and the `globalClockSync` / `globalDeprecationWarned` package variables | +| `pkg/sendspin/player.go` | Delete the `sync.SetGlobalClockSync(recv.ClockSync())` compatibility-shim call (currently at line 157 per PR #26 final state) | +| `.github/workflows/release.yml` | Remove `ma-player` from build matrix if present | +| `Makefile` | Remove any `ma-player` or `test-sync` targets if present | + +Total deletion: ~2500–2600 lines across ~17 files, plus small modifications to 5 surviving files. + +--- + +## Pre-flight: Branch setup + +- [ ] **Step 0a: Start from clean main** + +```bash +git checkout main +git pull --ff-only +git worktree add -b feat/rip-legacy-cli ../sendspin-go-worktrees/rip-legacy-cli origin/main +cd ../sendspin-go-worktrees/rip-legacy-cli +``` + +- [ ] **Step 0b: Verify baseline green before any changes** + +```bash +export PATH="/c/msys64/mingw64/bin:$PATH" +go build ./... +go test -count=1 ./... 2>&1 | tail -25 +``` +Expected: all packages pass. If anything is red before you start, stop and investigate. + +- [ ] **Step 0c: Commit the plan doc on the feature branch** + +```bash +# If the plan file is on main, pull it in. If the plan lives elsewhere, cp or cherry-pick. +# Assuming the plan file is already at docs/superpowers/plans/2026-04-14-rip-legacy-cli-stack.md on main: +git log --oneline main -- docs/superpowers/plans/2026-04-14-rip-legacy-cli-stack.md +# If the file exists, it's already on the branch via the fresh origin/main base. +# If not, commit it explicitly: +# git add docs/superpowers/plans/2026-04-14-rip-legacy-cli-stack.md +# git commit -m "docs: add rip-legacy-cli implementation plan" +``` + +--- + +### Task 1: Migrate `internal/ui` to `pkg/sync.Quality` + +`internal/ui/model.go` and its test file both import `internal/sync` for the `Quality` enum. Switch them to `pkg/sync`, which has the same type and the same constants (`QualityGood`, `QualityDegraded`, `QualityLost`). After this task, `internal/ui` no longer imports `internal/sync`. + +**Files:** +- Modify: `internal/ui/model.go` +- Modify: `internal/ui/model_test.go` + +- [ ] **Step 1: Update the import in `internal/ui/model.go`** + +Find the line: + +```go +"github.com/Sendspin/sendspin-go/internal/sync" +``` + +Replace with: + +```go +"github.com/Sendspin/sendspin-go/pkg/sync" +``` + +The package name used in the file is `sync` in both cases (no alias needed). All existing references like `sync.Quality`, `sync.QualityGood`, `sync.QualityDegraded`, `sync.QualityLost` continue to resolve correctly because `pkg/sync` has the same identifiers. + +- [ ] **Step 2: Update the import in `internal/ui/model_test.go`** + +Same replacement. If the test file uses `sync.Quality*` constants they all resolve against the new package identically. + +- [ ] **Step 3: Verify `internal/ui` builds and tests pass** + +```bash +go build ./internal/ui/ +go test ./internal/ui/ +``` +Expected: clean build, all tests pass. + +- [ ] **Step 4: Verify `main.go` still builds** + +```bash +go build . +``` +Expected: clean. `main.go` still converts `pkg/sync.Quality` → `internal/sync.Quality` in its own code at this point; that conversion keeps working because `internal/sync` is still alive. + +- [ ] **Step 5: Commit** + +```bash +git add internal/ui/model.go internal/ui/model_test.go +git commit -m "refactor(internal/ui): use pkg/sync.Quality instead of internal/sync.Quality + +pkg/sync.Quality is byte-for-byte equivalent to internal/sync.Quality +(same type, same three constants in the same iota order). Switching +the TUI's only internal/sync consumer is step 1 of deleting the +legacy internal/ stack." +``` + +--- + +### Task 2: Drop the `internal/sync` conversion layer in `main.go` + +`main.go` currently imports both `pkg/sync` (as `sync`) and `internal/sync` (as `internalsync`), and explicitly converts `pkg/sync.Quality` to `internal/sync.Quality` before constructing a `ui.StatusMsg`. After Task 1, `ui.StatusMsg.SyncQuality` is typed as `pkg/sync.Quality`, so the conversion is dead ceremony. Remove it and drop the `internal/sync` import. + +**Files:** +- Modify: `main.go` + +- [ ] **Step 1: Remove the `internalsync` import** + +Find in the import block: + +```go +internalsync "github.com/Sendspin/sendspin-go/internal/sync" +``` + +Delete that line. + +- [ ] **Step 2: Remove the Quality conversion block** + +Find in `statsUpdateLoop` (currently around lines 207–216): + +```go + // Convert pkg/sync.Quality to internal/sync.Quality + var syncQuality internalsync.Quality + switch stats.SyncQuality { + case 0: // QualityGood + syncQuality = internalsync.QualityGood + case 1: // QualityDegraded + syncQuality = internalsync.QualityDegraded + case 2: // QualityLost + syncQuality = internalsync.QualityLost + } +``` + +Delete the entire block. + +Then find the `ui.StatusMsg` construction that uses the `syncQuality` local variable (a few lines below the deleted block): + +```go + updateTUI(ui.StatusMsg{ + Received: stats.Received, + Played: stats.Played, + Dropped: stats.Dropped, + BufferDepth: stats.BufferDepth, + SyncRTT: stats.SyncRTT, + SyncQuality: syncQuality, + Goroutines: runtime.NumGoroutine(), + MemAlloc: 0, + MemSys: 0, + }) +``` + +Change the `SyncQuality: syncQuality,` line to: + +```go + SyncQuality: stats.SyncQuality, +``` + +(Passing `pkg/sync.Quality` directly, now that `ui.StatusMsg.SyncQuality` is typed as `pkg/sync.Quality` after Task 1.) + +- [ ] **Step 3: Verify the root binary builds** + +```bash +go build . +``` +Expected: clean build, produces `sendspin-player` (or `sendspin-player.exe` on Windows). If you see `undefined: internalsync`, a reference was missed — grep `internalsync` in `main.go` and remove any remaining hits. + +- [ ] **Step 4: Verify full build and tests** + +```bash +go build ./... +go test ./... 2>&1 | tail -25 +``` +Expected: all packages still pass. `internal/sync` is still present and still imported by `internal/app/player.go` and `internal/player/scheduler.go` — those die in a later task. + +- [ ] **Step 5: Commit** + +```bash +git add main.go +git commit -m "refactor(main): drop internal/sync conversion layer + +ui.StatusMsg.SyncQuality is now pkg/sync.Quality (see prior commit), +so the pkg/sync -> internal/sync conversion block is pure ceremony. +Pass stats.SyncQuality through directly." +``` + +--- + +### Task 3: Delete `cmd/ma-player` and `cmd/test-sync` + +Both CLIs import `internal/app`, which is the only non-legacy consumer of that package. Deleting them orphans `internal/app` for deletion in the next task. + +**Files:** +- Delete: `cmd/ma-player/main.go` +- Delete: `cmd/test-sync/main.go` + +- [ ] **Step 1: Confirm these are the only importers of `internal/app`** + +```bash +grep -rln "\"github.com/Sendspin/sendspin-go/internal/app\"" --include='*.go' . +``` +Expected output (exactly): +``` +./cmd/ma-player/main.go +./cmd/test-sync/main.go +``` +If there are other hits, STOP and report as BLOCKED — the scope was assumed wrong. + +- [ ] **Step 2: Delete the CLI directories** + +```bash +git rm -r cmd/ma-player cmd/test-sync +``` + +- [ ] **Step 3: Check the Makefile for references** + +```bash +grep -n "ma-player\|test-sync" Makefile +``` + +If any targets or recipes name `ma-player` or `test-sync`, delete those lines. The existing `build`, `server`, `player`, `test`, `lint`, `clean`, `install` targets should be unaffected. Example: if you see + +```makefile +ma-player: + go build -o ma-player ./cmd/ma-player +``` + +delete the target and any references to it in the phony list or the top-level `all:`/`build:` dependency lists. + +- [ ] **Step 4: Check the release workflow for references** + +```bash +grep -n "ma-player\|test-sync" .github/workflows/release.yml +``` + +If any build steps name these binaries, delete them. This partially addresses issue #35 (release workflow gap) by removing the entries that will no longer exist. + +- [ ] **Step 5: Verify the full module still builds** + +```bash +go build ./... +``` +Expected: clean. Nothing compiles against `cmd/ma-player` or `cmd/test-sync` (they were standalone `package main` binaries), so the rest of the tree builds fine. + +- [ ] **Step 6: Verify `internal/app` now has no importers** + +```bash +grep -rln "\"github.com/Sendspin/sendspin-go/internal/app\"" --include='*.go' . +``` +Expected output: empty (no hits). + +- [ ] **Step 7: Commit** + +```bash +git add -A cmd/ Makefile .github/workflows/release.yml +git commit -m "refactor: delete cmd/ma-player and cmd/test-sync + +ma-player duplicated sendspin-player's functionality via the legacy +internal/app stack; verified empirically that sendspin-player.exe +handles the Music Assistant case cleanly, including 192kHz/24-bit +PCM hi-res streaming (v1.2.0 verification logs). test-sync was a +one-off clock-sync debug harness with no remaining users. Removing +both is step 1 of deleting the duplicate internal/ pipeline." +``` + +--- + +### Task 4: Delete `internal/app` + +After Task 3, no caller remains. `internal/app/player.go` and its test file both die. This orphans `internal/audio`, `internal/client`, `internal/player`, `internal/artwork`, `internal/version`, and `internal/discovery` of their legacy consumer — though some of those packages (version, discovery) still have non-legacy consumers and survive. + +**Files:** +- Delete: `internal/app/player.go` +- Delete: `internal/app/player_test.go` + +- [ ] **Step 1: Confirm `internal/app` still has zero importers** + +```bash +grep -rln "\"github.com/Sendspin/sendspin-go/internal/app\"" --include='*.go' . +``` +Expected output: empty. + +- [ ] **Step 2: Delete the package** + +```bash +git rm -r internal/app +``` + +- [ ] **Step 3: Verify build and test** + +```bash +go build ./... +go test -count=1 ./... 2>&1 | tail -25 +``` +Expected: all surviving packages build and pass. `internal/audio`, `internal/client`, `internal/player`, `internal/artwork` will still build independently — their tests continue to pass within their own packages because they don't depend on `internal/app`. The one surprise that could happen is if any package depends on `internal/app` in a way the grep missed (e.g., via test helpers or a build-tagged file). If the build fails, STOP and report. + +- [ ] **Step 4: Commit** + +```bash +git add -A internal/app/ +git commit -m "refactor(internal/app): delete legacy player orchestrator + +Only callers were cmd/ma-player and cmd/test-sync, both deleted in +the previous commit. This removes ~800 lines of duplicate pipeline +wiring that predated pkg/sendspin." +``` + +--- + +### Task 5: Delete `internal/player` + +`internal/player/scheduler.go` was only used by `internal/app`. After Task 4, it has no importers. This is the last non-test consumer of `internal/audio` and (along with the earlier `internal/ui`/`main.go` migration) of `internal/sync`. + +**Files:** +- Delete: `internal/player/scheduler.go` +- Delete: `internal/player/scheduler_test.go` + +- [ ] **Step 1: Confirm `internal/player` has zero importers** + +```bash +grep -rln "\"github.com/Sendspin/sendspin-go/internal/player\"" --include='*.go' . +``` +Expected output: empty. + +- [ ] **Step 2: Delete the package** + +```bash +git rm -r internal/player +``` + +- [ ] **Step 3: Verify build and test** + +```bash +go build ./... +go test -count=1 ./... 2>&1 | tail -25 +``` +Expected: all packages pass. + +- [ ] **Step 4: Commit** + +```bash +git add -A internal/player/ +git commit -m "refactor(internal/player): delete legacy scheduler + +The only caller, internal/app, was deleted in the previous commit. +pkg/sendspin has its own Scheduler that serves the modern player +pipeline." +``` + +--- + +### Task 6: Delete the remaining orphaned `internal/` packages + +After Tasks 4 and 5, the following packages have no importers anywhere in the tree: + +- `internal/audio/` (decoder.go, decoder_test.go, types.go) +- `internal/client/` (websocket.go, websocket_test.go) +- `internal/artwork/` (downloader.go, downloader_test.go) +- `internal/sync/` (clock.go, clock_test.go) +- `internal/protocol/` (messages.go, messages_test.go) — already orphaned before this PR; deleting it now finishes the sweep + +**Files:** +- Delete: `internal/audio/` (directory) +- Delete: `internal/client/` (directory) +- Delete: `internal/artwork/` (directory) +- Delete: `internal/sync/` (directory) +- Delete: `internal/protocol/` (directory) + +- [ ] **Step 1: Confirm every target package has zero importers** + +```bash +for pkg in audio client artwork sync protocol; do + echo "=== internal/$pkg ===" + grep -rln "\"github.com/Sendspin/sendspin-go/internal/$pkg\"" --include='*.go' . || echo "(clean)" +done +``` +Expected: all five should print `(clean)`. If any one still has importers, STOP and report — earlier tasks missed something. + +- [ ] **Step 2: Delete the five packages** + +```bash +git rm -r internal/audio internal/client internal/artwork internal/sync internal/protocol +``` + +- [ ] **Step 3: Verify build and test** + +```bash +go build ./... +go test -count=1 ./... 2>&1 | tail -25 +``` +Expected: all surviving packages pass. The only remaining `internal/` packages should now be: + +```bash +ls internal/ +# expected: discovery server ui version +``` + +- [ ] **Step 4: Commit** + +```bash +git add -A internal/ +git commit -m "refactor: delete orphaned internal/ packages + +After removing internal/app and internal/player, the following +packages have no remaining importers in the tree: + + - internal/audio (duplicate of pkg/audio/decode) + - internal/client (duplicate of pkg/protocol.Client) + - internal/artwork (unused by pkg/sendspin.Player) + - internal/sync (duplicate of pkg/sync) + - internal/protocol (already orphaned pre-PR) + +Surviving internal/ packages: discovery, server, ui, version." +``` + +--- + +### Task 7: Delete the `pkg/sync` deprecation shims + +With `internal/player/scheduler.go` and `internal/app/player.go` deleted, nothing in the tree reads from the package-level `sync.ServerMicrosNow()` or writes to the global via `sync.SetGlobalClockSync()`. The compatibility shim at `pkg/sendspin/player.go` (line ~157 per PR #26 final state) also becomes dead — it was only there so the `internal/` readers could see the `ClockSync` via the global. + +This resolves issue #28 (sync deprecation migration) in full. + +**Files:** +- Modify: `pkg/sync/clock.go` +- Modify: `pkg/sendspin/player.go` + +- [ ] **Step 1: Confirm no caller of `sync.ServerMicrosNow()` or `sync.SetGlobalClockSync`** + +```bash +grep -rn "sync\.ServerMicrosNow\|sync\.SetGlobalClockSync" --include='*.go' . | grep -v "_test.go" | grep -v "pkg/sync/clock.go" +``` +Expected: no hits in production code. Test files may still reference the deprecated symbols if the `pkg/sync` package's own tests exercise them — those will need updating in Step 3 if so. + +- [ ] **Step 2: Delete the deprecated package-level functions from `pkg/sync/clock.go`** + +In `pkg/sync/clock.go`, find and delete these three sections: + +```go +// Deprecated: ServerMicrosNow returns current time in server's reference frame (us). +// Use ClockSync.ServerMicrosNow() on the instance from Receiver.ClockSync() instead. +func ServerMicrosNow() int64 { + cs := globalClockSync + // ... (entire function body) +} +``` + +```go +var ( + globalClockSync *ClockSync + globalDeprecationWarned bool +) + +// Deprecated: SetGlobalClockSync sets the global clock sync instance. +// Use Receiver.ClockSync() instead for new code. +func SetGlobalClockSync(cs *ClockSync) { + if !globalDeprecationWarned { + log.Printf("Warning: SetGlobalClockSync is deprecated, use Receiver.ClockSync() instead") + globalDeprecationWarned = true + } + globalClockSync = cs +} +``` + +Delete both declarations (`var (...)` block and both functions) entirely. Do NOT touch the instance method `(cs *ClockSync) ServerMicrosNow()` — that one stays; it's the replacement API. + +After the delete, check whether `pkg/sync/clock.go` still imports `"log"`. If `log.Printf` is no longer called anywhere in the file, `go build` will flag the unused import — remove it. + +- [ ] **Step 3: Update `pkg/sync/clock_test.go` if it references the deleted symbols** + +```bash +grep -n "ServerMicrosNow\b\|SetGlobalClockSync\|globalClockSync" pkg/sync/clock_test.go +``` + +If the test file references the package-level `ServerMicrosNow()` (no receiver) or `SetGlobalClockSync`, those tests need to migrate to the instance method `cs.ServerMicrosNow()` or be deleted. In particular, if there's a `TestServerMicrosNow` (package-level) AND a `TestClockSync_ServerMicrosNow` (instance method), keep the latter and delete the former. + +If `pkg/sync/clock_test.go` has no references to the deleted symbols, skip this step. + +- [ ] **Step 4: Delete the compat shim from `pkg/sendspin/player.go`** + +In `pkg/sendspin/player.go`, find the line (currently around line 157, inside `Connect()`): + +```go + // Backward compat: set global clock sync + sync.SetGlobalClockSync(recv.ClockSync()) +``` + +Delete both lines (the comment and the call). + +After the delete, check whether `pkg/sendspin/player.go` still uses the `"github.com/Sendspin/sendspin-go/pkg/sync"` import for anything. If `sync.` no longer appears in the file, remove the import. If it appears for other types (e.g., `sync.Quality`), leave the import alone. + +- [ ] **Step 5: Verify build and test** + +```bash +go build ./... +go test -count=1 ./... 2>&1 | tail -25 +``` +Expected: all packages pass. Particularly `pkg/sync`, `pkg/sendspin`, and `main.go` all still build cleanly. + +- [ ] **Step 6: Grep to confirm the deprecation warning string is gone** + +```bash +grep -rn "SetGlobalClockSync is deprecated" --include='*.go' . +``` +Expected: no hits. The deprecation warning no longer exists because the function that emits it no longer exists. + +- [ ] **Step 7: Commit** + +```bash +git add pkg/sync/clock.go pkg/sync/clock_test.go pkg/sendspin/player.go +git commit -m "refactor(pkg/sync): delete deprecated global clock-sync shims + +The only callers of sync.ServerMicrosNow() (package-level) and +sync.SetGlobalClockSync() lived in the internal/ legacy stack, +which was deleted in earlier commits. The instance method +(*ClockSync).ServerMicrosNow() is now the only way to read +server-frame time. + +Resolves #28." +``` + +--- + +### Task 8: Final sweep and `go mod tidy` + +Dropping the `internal/` packages may have orphaned module dependencies that were only pulled in by the legacy stack. Run `go mod tidy` to clean `go.mod` and `go.sum`. + +**Files:** +- Modify: `go.mod`, `go.sum` (if anything changed) + +- [ ] **Step 1: Run `go mod tidy`** + +```bash +go mod tidy +``` + +- [ ] **Step 2: Review the diff** + +```bash +git diff go.mod go.sum +``` + +If `go.mod` / `go.sum` are unchanged, skip steps 3 and 4. If they are changed, read the diff — you should only see REMOVALS (a dependency that `internal/client` or `internal/audio` was the only user of). Any ADDITION is suspicious; investigate before committing. + +Candidate dependencies that may disappear: +- Anything the legacy `internal/client/websocket.go` used that `pkg/protocol/client.go` doesn't +- Anything `internal/audio/decoder.go` used that `pkg/audio/decode/` doesn't + +- [ ] **Step 3: Verify final build and full test suite** + +```bash +go build ./... +go test -count=1 ./... 2>&1 | tail -25 +``` +Expected: clean + all packages pass. + +- [ ] **Step 4: Commit (skip if `go mod tidy` was a no-op)** + +```bash +git add go.mod go.sum +git commit -m "chore(deps): go mod tidy after legacy stack removal" +``` + +- [ ] **Step 5: Final structural check** + +```bash +ls internal/ +# expected: discovery server ui version + +find cmd -type d +# expected: cmd cmd/sendspin-server +``` + +- [ ] **Step 6: Sanity: grep for any string references to deleted binaries** + +```bash +grep -rn "ma-player\|test-sync" --include='*.md' --include='*.yml' --include='*.sh' --include='Makefile' . +``` + +Review every hit. Historical notes in `CHANGELOG.md`, `docs/PHASE1_IMPLEMENTATION.md`, and frozen plan docs under `docs/superpowers/plans/` may legitimately mention them — leave those alone. Current-state references (instructions, scripts, CI configs) need cleanup. Commit any doc updates as a separate trailing commit if needed: + +```bash +git add +git commit -m "docs: remove stale references to deleted ma-player/test-sync" +``` + +--- + +### Task 9: Open the PR + +- [ ] **Step 1: Push the feature branch** + +```bash +git push -u origin feat/rip-legacy-cli +``` + +- [ ] **Step 2: Open the PR** + +```bash +gh pr create --title "refactor: delete legacy internal/ CLI stack (ma-player, test-sync, internal/app and deps)" --body "$(cat <<'EOF' +## Summary + +Delete \`cmd/ma-player\`, \`cmd/test-sync\`, and the entire \`internal/\` legacy player pipeline they depended on: + +- \`cmd/ma-player/\`, \`cmd/test-sync/\` +- \`internal/app/\`, \`internal/audio/\`, \`internal/client/\`, \`internal/player/\`, \`internal/artwork/\`, \`internal/sync/\`, \`internal/protocol/\` + +Surviving \`internal/\` packages: \`discovery\`, \`server\`, \`ui\`, \`version\`. + +Also removes the \`pkg/sync.SetGlobalClockSync\` / \`ServerMicrosNow\` package-level deprecation shims (and the compat-shim call in \`pkg/sendspin.Player.Connect\`), now that no readers remain. Resolves #28. + +## Why + +\`ma-player\` duplicated \`sendspin-player\`'s functionality via the legacy \`internal/\` stack. After PR #26 migrated \`internal/app.Player\`'s output layer to \`pkg/audio/output.Malgo\`, the only difference between the two binaries was that one went through \`pkg/sendspin.Receiver\` and the other went through \`internal/app.Player\` → \`internal/client\` → \`internal/player.Scheduler\`. Both negotiate the same hi-res-first format list, both route 24-bit PCM through malgo, both connect to Music Assistant. Empirically verified: \`sendspin-player.exe\` was tested end-to-end against a real Music Assistant server at 192kHz/24-bit during the v1.2.0 verification pass — log evidence in \`sendspin-player.log\`. + +\`test-sync\` was a one-off clock-sync debugging CLI from the earlier "make server sync work" era; nobody runs it today. It's in git history if anyone needs it back. + +## Scope + +- **In:** delete the two CLIs and the seven orphaned \`internal/\` packages; migrate \`internal/ui\` and \`main.go\` off \`internal/sync.Quality\` onto \`pkg/sync.Quality\`; delete the \`pkg/sync\` deprecation shims. +- **Out:** \`internal/server\` (still used by \`cmd/sendspin-server\` and \`pkg/sendspin/server.go\`), \`internal/discovery\`, \`internal/ui\`, \`internal/version\` — all still have non-legacy consumers. + +## Stats + +~2500–2600 lines deleted across ~17 files. No new functionality, no behavioural change to \`sendspin-player\` or \`sendspin-server\`. + +## Resolves + +- #28 — deprecated \`sync.SetGlobalClockSync\` / \`sync.ServerMicrosNow\` shims removed along with their last readers. +- Partially addresses #35 — release workflow now only needs to ship \`sendspin-player\` and \`sendspin-server\` (ma-player gone). + +## Test plan + +- [x] \`go build ./...\` clean +- [x] \`go test -count=1 ./...\` green on every surviving package +- [ ] **Manual playback verification** — run \`./sendspin-player.exe --server \` 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. diff --git a/third_party/sendspin-go/docs/superpowers/plans/2026-04-14-server-initiated-client-discovery.md b/third_party/sendspin-go/docs/superpowers/plans/2026-04-14-server-initiated-client-discovery.md new file mode 100644 index 0000000..a3b54ca --- /dev/null +++ b/third_party/sendspin-go/docs/superpowers/plans/2026-04-14-server-initiated-client-discovery.md @@ -0,0 +1,1372 @@ +# Server-Initiated Client Discovery 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:** Let a Sendspin server browse `_sendspin._tcp.local.` mDNS advertisements, dial out to each discovered player via WebSocket, and funnel the resulting connections into the existing `handleConnection` code path with zero changes below the transport layer. + +**Architecture:** A new `ClientDialer` component owned by `pkg/sendspin.Server` subscribes to a new `discovery.Manager.BrowseClients()` stream, dedupes by mDNS instance name, dials each advertised `ws://host:port{path}` endpoint with exponential backoff, and hands the upgraded `*websocket.Conn` to `s.handleConnection` — the same entry point inbound connections already use. Dedupe-by-`ClientID` at the protocol layer (`server.go:394`-ish) provides a second safety net for races against inbound connections. + +**Tech Stack:** Go 1.24, `github.com/hashicorp/mdns` v1.0.6 (browsing + TXT parsing), `github.com/gorilla/websocket` v1.5.3 (dialing), standard library `testing`. + +--- + +## Spec Reference + +From https://www.sendspin-audio.com/spec/ (server-initiated discovery mode): + +- Service type: `_sendspin._tcp.local.` (note: client-advertised, not server-advertised) +- Recommended port: `8928` +- TXT records: `path=` (typically `/sendspin`), `name=` (optional) +- After dial: player still sends `client/hello` first; server responds `server/hello` +- Mutual exclusion is per-client: a player must NOT both advertise `_sendspin._tcp` AND manually connect to a server + +The server side we're building can freely run both modes in parallel — dedupe handles collisions. + +--- + +## File Structure + +**Modified:** +- `internal/discovery/mdns.go` — add `ClientInfo`, `BrowseClients()`, TXT parsing helper, `parseClientEntry()` +- `internal/discovery/mdns_test.go` — add tests for TXT parsing and entry conversion +- `pkg/sendspin/server.go` — add `DiscoverClients` config field, wire `ClientDialer` in `Start()` / `Stop()` +- `pkg/sendspin/server_test.go` — add integration test with a fake mDNS advertiser +- `cmd/sendspin-server/main.go` — add `-discover-clients` flag + +**Created:** +- `pkg/sendspin/client_dialer.go` — `ClientDialer` struct, goroutine loop, backoff, instance-name dedupe +- `pkg/sendspin/client_dialer_test.go` — unit tests with injected dial func +- `internal/discovery/txt.go` — pure TXT parsing helper (no mDNS dependency, easier to test) +- `internal/discovery/txt_test.go` — table-driven tests for TXT parsing + +Each file has one responsibility: discovery emits `ClientInfo`, the dialer turns `ClientInfo` into `*websocket.Conn`, and `handleConnection` remains untouched. + +--- + +## Task 1: TXT Record Parsing Helper + +**Files:** +- Create: `internal/discovery/txt.go` +- Create: `internal/discovery/txt_test.go` + +Why a standalone file: `hashicorp/mdns` stores TXT records as `[]string` of raw `key=value` entries in `ServiceEntry.InfoFields`. Parsing is a pure function with no mDNS types, so we isolate and test it in isolation before touching the browser loop. + +- [ ] **Step 1: Write failing tests for `parseTXT`** + +```go +// internal/discovery/txt_test.go +// 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) + } + }) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/discovery/ -run TestParseTXT -v` +Expected: FAIL with `undefined: parseTXT`. + +- [ ] **Step 3: Implement `parseTXT`** + +```go +// internal/discovery/txt.go +// 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 +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/discovery/ -run TestParseTXT -v` +Expected: PASS for all 7 subtests. + +- [ ] **Step 5: Commit** + +```bash +git add internal/discovery/txt.go internal/discovery/txt_test.go +git commit -m "feat(discovery): add TXT record parsing helper" +``` + +--- + +## Task 2: ClientInfo Type and Entry Conversion + +**Files:** +- Modify: `internal/discovery/mdns.go` (add types + function near `ServerInfo` at lines 33-38) +- Modify: `internal/discovery/mdns_test.go` (add tests) + +Why separate from Task 3: converting a `*mdns.ServiceEntry` into a `ClientInfo` is pure logic that we can test without spinning up an mDNS loop. Isolating it keeps the browse goroutine in Task 3 trivial. + +- [ ] **Step 1: Write failing test for `clientInfoFromEntry`** + +Add to `internal/discovery/mdns_test.go`: + +```go +import ( + "net" + "testing" + + "github.com/hashicorp/mdns" +) + +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, + }, + } + + 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) + } + }) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/discovery/ -run TestClientInfoFromEntry -v` +Expected: FAIL with `undefined: ClientInfo` and `undefined: clientInfoFromEntry`. + +- [ ] **Step 3: Add `ClientInfo` and `clientInfoFromEntry` to `mdns.go`** + +Insert after the `ServerInfo` struct (currently `internal/discovery/mdns.go:33-38`): + +```go +// 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. +func clientInfoFromEntry(entry *mdns.ServiceEntry) *ClientInfo { + if entry == nil || entry.AddrV4 == nil || entry.Port == 0 { + 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, + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/discovery/ -run TestClientInfoFromEntry -v` +Expected: PASS for all 5 subtests. + +- [ ] **Step 5: Commit** + +```bash +git add internal/discovery/mdns.go internal/discovery/mdns_test.go +git commit -m "feat(discovery): add ClientInfo type and entry conversion" +``` + +--- + +## Task 3: BrowseClients Method + +**Files:** +- Modify: `internal/discovery/mdns.go` (add channel field, method, loop) +- Modify: `internal/discovery/mdns_test.go` (add sanity test that the channel is exposed) + +- [ ] **Step 1: Write failing test that `Clients()` returns a readable channel** + +Add to `internal/discovery/mdns_test.go`: + +```go +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: + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/discovery/ -run TestManagerExposesClientsChannel -v` +Expected: FAIL with `mgr.Clients undefined`. + +- [ ] **Step 3: Add `clients` channel and `Clients()` / `BrowseClients()` methods** + +In `internal/discovery/mdns.go`, modify the `Manager` struct (currently lines 26-31) to add a clients channel: + +```go +type Manager struct { + config Config + ctx context.Context + cancel context.CancelFunc + servers chan *ServerInfo + clients chan *ClientInfo +} +``` + +Update `NewManager` (currently lines 41-50) to initialize the new channel: + +```go +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), + } +} +``` + +Add these methods at the bottom of `mdns.go` (before `getLocalIPs`): + +```go +// BrowseClients searches for Sendspin clients advertising _sendspin._tcp. +func (m *Manager) BrowseClients() error { + go m.browseClientsLoop() + return nil +} + +// Clients returns the channel of discovered clients. +func (m *Manager) Clients() <-chan *ClientInfo { + return m.clients +} + +// browseClientsLoop continuously browses for 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, + Entries: entries, + Logger: silentLogger, + } + + if err := mdns.Query(params); err != nil { + log.Printf("mdns query error: %v", err) + } + close(entries) + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/discovery/ -v` +Expected: PASS for all discovery tests including the new `TestManagerExposesClientsChannel`. + +- [ ] **Step 5: Commit** + +```bash +git add internal/discovery/mdns.go internal/discovery/mdns_test.go +git commit -m "feat(discovery): browse for clients advertising _sendspin._tcp" +``` + +--- + +## Task 4: WebSocket Dial Helper + +**Files:** +- Create: `pkg/sendspin/client_dialer.go` (just the helper function for now) +- Create: `pkg/sendspin/client_dialer_test.go` + +Why a thin helper before the full dialer: we want one place that turns a `discovery.ClientInfo` into a dialed URL string, so the tests for URL construction don't need a real network or an mDNS stack. + +- [ ] **Step 1: Write failing test for `clientInfoURL`** + +```go +// pkg/sendspin/client_dialer_test.go +// ABOUTME: Tests for outbound client discovery and dialing +// ABOUTME: Uses injected dial functions — no real network I/O + +package sendspin + +import ( + "testing" + + "github.com/Sendspin/sendspin-go/internal/discovery" +) + +func TestClientInfoURL(t *testing.T) { + tests := []struct { + name string + info *discovery.ClientInfo + want string + }{ + { + name: "ipv4 host", + info: &discovery.ClientInfo{Host: "192.168.1.42", Port: 8928, Path: "/sendspin"}, + want: "ws://192.168.1.42:8928/sendspin", + }, + { + name: "path missing leading slash is normalized", + info: &discovery.ClientInfo{Host: "192.168.1.42", Port: 8928, Path: "sendspin"}, + want: "ws://192.168.1.42:8928/sendspin", + }, + { + name: "custom path preserved", + info: &discovery.ClientInfo{Host: "10.0.0.5", Port: 9000, Path: "/custom/endpoint"}, + want: "ws://10.0.0.5:9000/custom/endpoint", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := clientInfoURL(tt.info) + if got != tt.want { + t.Errorf("clientInfoURL = %q, want %q", got, tt.want) + } + }) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./pkg/sendspin/ -run TestClientInfoURL -v` +Expected: FAIL with `undefined: clientInfoURL`. + +- [ ] **Step 3: Create `client_dialer.go` with the helper** + +```go +// pkg/sendspin/client_dialer.go +// ABOUTME: Outbound client discovery dialer for server-initiated mode +// ABOUTME: Converts discovery.ClientInfo into dialed WebSocket connections + +package sendspin + +import ( + "fmt" + "strings" + + "github.com/Sendspin/sendspin-go/internal/discovery" +) + +// clientInfoURL builds a ws:// URL from a discovered ClientInfo. +// Normalizes Path to ensure a single leading slash. +func clientInfoURL(info *discovery.ClientInfo) string { + path := info.Path + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + return fmt.Sprintf("ws://%s:%d%s", info.Host, info.Port, path) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./pkg/sendspin/ -run TestClientInfoURL -v` +Expected: PASS for all 3 subtests. + +- [ ] **Step 5: Commit** + +```bash +git add pkg/sendspin/client_dialer.go pkg/sendspin/client_dialer_test.go +git commit -m "feat(sendspin): add URL builder for discovered client dialing" +``` + +--- + +## Task 5: ClientDialer Struct with Instance Dedupe + +**Files:** +- Modify: `pkg/sendspin/client_dialer.go` +- Modify: `pkg/sendspin/client_dialer_test.go` + +The dialer owns a goroutine that reads `*discovery.ClientInfo` from an input channel and calls a `dialFunc` for each new instance. The dedupe map is keyed by `Instance` (the mDNS fully-qualified name — stable across browse cycles). We inject the dial function so tests never touch the network. + +- [ ] **Step 1: Write failing test for dedupe behavior** + +Add to `pkg/sendspin/client_dialer_test.go`: + +```go +import ( + "context" + "sync" + "sync/atomic" + "time" +) + +func TestClientDialerDedupesByInstance(t *testing.T) { + in := make(chan *discovery.ClientInfo, 10) + + var dialCalls int32 + var mu sync.Mutex + var seen []string + + dial := func(ctx context.Context, info *discovery.ClientInfo) error { + atomic.AddInt32(&dialCalls, 1) + mu.Lock() + seen = append(seen, info.Instance) + mu.Unlock() + return nil // success — slot stays occupied + } + + d := newClientDialer(in, dial) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan struct{}) + go func() { + d.run(ctx) + close(done) + }() + + // Emit the same instance 3 times and a different instance once + in <- &discovery.ClientInfo{Instance: "a._sendspin._tcp.local.", Host: "1.1.1.1", Port: 8928, Path: "/sendspin"} + in <- &discovery.ClientInfo{Instance: "a._sendspin._tcp.local.", Host: "1.1.1.1", Port: 8928, Path: "/sendspin"} + in <- &discovery.ClientInfo{Instance: "b._sendspin._tcp.local.", Host: "1.1.1.2", Port: 8928, Path: "/sendspin"} + in <- &discovery.ClientInfo{Instance: "a._sendspin._tcp.local.", Host: "1.1.1.1", Port: 8928, Path: "/sendspin"} + + // Wait for the dialer to drain + deadline := time.After(500 * time.Millisecond) + for { + if atomic.LoadInt32(&dialCalls) >= 2 { + break + } + select { + case <-deadline: + t.Fatalf("timed out waiting for dial calls, got %d", atomic.LoadInt32(&dialCalls)) + case <-time.After(10 * time.Millisecond): + } + } + + // Give any extra (incorrect) calls a chance to fire + time.Sleep(50 * time.Millisecond) + + cancel() + <-done + + if got := atomic.LoadInt32(&dialCalls); got != 2 { + t.Errorf("dialCalls = %d, want 2 (one per unique instance)", got) + } + mu.Lock() + defer mu.Unlock() + if len(seen) != 2 { + t.Fatalf("seen = %v, want 2 entries", seen) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./pkg/sendspin/ -run TestClientDialerDedupesByInstance -v` +Expected: FAIL with `undefined: newClientDialer`. + +- [ ] **Step 3: Add `clientDialer` struct and `run` loop to `client_dialer.go`** + +Append to `pkg/sendspin/client_dialer.go`: + +```go +import ( + "context" + "log" + "sync" +) + +// dialFunc dials a discovered client and returns when the resulting +// connection has been fully handled (e.g. handleConnection returned). +// Returning nil means the connection completed normally; returning an +// error means the dial or handshake failed and the slot should be +// released for retry. +type dialFunc func(ctx context.Context, info *discovery.ClientInfo) error + +// clientDialer consumes discovery events and dispatches one goroutine +// per unique mDNS instance, deduping so we never open two sockets to +// the same advertisement concurrently. +type clientDialer struct { + in <-chan *discovery.ClientInfo + dial dialFunc + + mu sync.Mutex + active map[string]bool // instance name -> currently dialing/connected +} + +func newClientDialer(in <-chan *discovery.ClientInfo, dial dialFunc) *clientDialer { + return &clientDialer{ + in: in, + dial: dial, + active: make(map[string]bool), + } +} + +// run pumps discovery events until ctx is cancelled. +func (d *clientDialer) run(ctx context.Context) { + var wg sync.WaitGroup + defer wg.Wait() + + for { + select { + case <-ctx.Done(): + return + case info, ok := <-d.in: + if !ok { + return + } + if info == nil { + continue + } + if !d.claim(info.Instance) { + continue + } + wg.Add(1) + go func(info *discovery.ClientInfo) { + defer wg.Done() + defer d.release(info.Instance) + if err := d.dial(ctx, info); err != nil { + log.Printf("dial client %s: %v", info.Instance, err) + } + }(info) + } + } +} + +// claim returns true if the instance slot was free and is now owned by +// the caller. Returns false if another goroutine already owns it. +func (d *clientDialer) claim(instance string) bool { + d.mu.Lock() + defer d.mu.Unlock() + if d.active[instance] { + return false + } + d.active[instance] = true + return true +} + +func (d *clientDialer) release(instance string) { + d.mu.Lock() + defer d.mu.Unlock() + delete(d.active, instance) +} +``` + +Note: the existing top of the file will now need its imports consolidated. After editing, the import block should read: + +```go +import ( + "context" + "fmt" + "log" + "strings" + "sync" + + "github.com/Sendspin/sendspin-go/internal/discovery" +) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./pkg/sendspin/ -run TestClientDialer -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add pkg/sendspin/client_dialer.go pkg/sendspin/client_dialer_test.go +git commit -m "feat(sendspin): add clientDialer with instance-name dedupe" +``` + +--- + +## Task 6: Exponential Backoff on Dial Failures + +**Files:** +- Modify: `pkg/sendspin/client_dialer.go` +- Modify: `pkg/sendspin/client_dialer_test.go` + +When a dial fails, we want to back off before accepting another discovery event for the same instance. The backoff is tracked per-instance and resets after a successful dial. + +- [ ] **Step 1: Write failing test for backoff** + +Add to `client_dialer_test.go`: + +```go +import "errors" + +func TestClientDialerBackoffOnFailure(t *testing.T) { + in := make(chan *discovery.ClientInfo, 10) + + var dialCalls int32 + dial := func(ctx context.Context, info *discovery.ClientInfo) error { + atomic.AddInt32(&dialCalls, 1) + return errors.New("boom") + } + + d := newClientDialer(in, dial) + // Override backoff clock to keep the test fast + d.baseBackoff = 20 * time.Millisecond + d.maxBackoff = 80 * time.Millisecond + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan struct{}) + go func() { + d.run(ctx) + close(done) + }() + + info := &discovery.ClientInfo{Instance: "a._sendspin._tcp.local.", Host: "1.1.1.1", Port: 8928, Path: "/sendspin"} + + // First event — should dial immediately + in <- info + + // Wait for first dial + waitForCalls(t, &dialCalls, 1, 200*time.Millisecond) + + // Immediately re-emit — should be rejected by backoff + in <- info + time.Sleep(5 * time.Millisecond) + if got := atomic.LoadInt32(&dialCalls); got != 1 { + t.Errorf("dialCalls after immediate retry = %d, want 1 (blocked by backoff)", got) + } + + // Wait past base backoff, re-emit — should dial again + time.Sleep(30 * time.Millisecond) + in <- info + waitForCalls(t, &dialCalls, 2, 200*time.Millisecond) + + cancel() + <-done +} + +func waitForCalls(t *testing.T, counter *int32, want int32, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if atomic.LoadInt32(counter) >= want { + return + } + time.Sleep(2 * time.Millisecond) + } + t.Fatalf("timed out waiting for %d calls, got %d", want, atomic.LoadInt32(counter)) +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./pkg/sendspin/ -run TestClientDialerBackoff -v` +Expected: FAIL with `d.baseBackoff undefined` (or the test blows past its assertion because backoff isn't implemented). + +- [ ] **Step 3: Add backoff state and logic** + +Modify the `clientDialer` struct in `pkg/sendspin/client_dialer.go`: + +```go +type clientDialer struct { + in <-chan *discovery.ClientInfo + dial dialFunc + + baseBackoff time.Duration + maxBackoff time.Duration + + mu sync.Mutex + active map[string]bool // currently dialing/connected + cooldown map[string]time.Time // instance -> earliest retry time + failures map[string]int // consecutive failure count per instance +} +``` + +Update the constructor: + +```go +func newClientDialer(in <-chan *discovery.ClientInfo, dial dialFunc) *clientDialer { + return &clientDialer{ + in: in, + dial: dial, + baseBackoff: 1 * time.Second, + maxBackoff: 30 * time.Second, + active: make(map[string]bool), + cooldown: make(map[string]time.Time), + failures: make(map[string]int), + } +} +``` + +Add `"time"` to the imports. + +Replace `claim` with a version that checks cooldown: + +```go +func (d *clientDialer) claim(instance string) bool { + d.mu.Lock() + defer d.mu.Unlock() + if d.active[instance] { + return false + } + if until, ok := d.cooldown[instance]; ok && time.Now().Before(until) { + return false + } + d.active[instance] = true + return true +} +``` + +Replace `release` with a version that records failures and sets cooldown: + +```go +func (d *clientDialer) release(instance string, dialErr error) { + d.mu.Lock() + defer d.mu.Unlock() + delete(d.active, instance) + + if dialErr == nil { + delete(d.failures, instance) + delete(d.cooldown, instance) + return + } + + d.failures[instance]++ + backoff := d.baseBackoff * time.Duration(1<<(d.failures[instance]-1)) + if backoff > d.maxBackoff { + backoff = d.maxBackoff + } + d.cooldown[instance] = time.Now().Add(backoff) +} +``` + +Update the `run` goroutine to pass the error into `release`: + +```go +wg.Add(1) +go func(info *discovery.ClientInfo) { + defer wg.Done() + err := d.dial(ctx, info) + d.release(info.Instance, err) + if err != nil { + log.Printf("dial client %s: %v", info.Instance, err) + } +}(info) +``` + +Important: the Task 5 test `TestClientDialerDedupesByInstance` calls `d.release(instance)` implicitly via the `run` loop. That test used a `dial` that returns `nil`, so with the new signature it still works — just update the goroutine call site once. + +- [ ] **Step 4: Run all dialer tests to verify they pass** + +Run: `go test ./pkg/sendspin/ -run TestClientDialer -v` +Expected: PASS for `TestClientDialerDedupesByInstance`, `TestClientDialerBackoffOnFailure`, `TestClientInfoURL`. + +- [ ] **Step 5: Commit** + +```bash +git add pkg/sendspin/client_dialer.go pkg/sendspin/client_dialer_test.go +git commit -m "feat(sendspin): add exponential backoff on dial failures" +``` + +--- + +## Task 7: Real Dial Function + Integration with handleConnection + +**Files:** +- Modify: `pkg/sendspin/client_dialer.go` (add real websocket dial) +- Modify: `pkg/sendspin/server.go` (wire the dialer into `Start`/`Stop`) + +The real dial function opens a WebSocket, then calls `s.handleConnection(conn)` synchronously. When `handleConnection` returns, the dial function returns nil (handled normally). Dial failures return an error so backoff kicks in. + +- [ ] **Step 1: Add `DiscoverClients` config field** + +Modify `pkg/sendspin/server.go` `ServerConfig` (currently lines 43-58): + +```go +type ServerConfig struct { + Port int + Name string + Source AudioSource + EnableMDNS bool + Debug bool + + // DiscoverClients enables server-initiated discovery: browse for + // clients advertising _sendspin._tcp and dial out to them. + // See https://www.sendspin-audio.com/spec/ — "server-initiated" mode. + DiscoverClients bool +} +``` + +Also add a field to the `Server` struct (currently lines 61-92) to hold the dialer's cancel: + +```go +// client dialer (server-initiated discovery) +dialerCancel context.CancelFunc +``` + +- [ ] **Step 2: Add the real dial function and `dialClient` helper to `client_dialer.go`** + +Append to `pkg/sendspin/client_dialer.go`: + +```go +import "github.com/gorilla/websocket" + +// dialAndHandle opens a WebSocket to a discovered client and hands the +// connection to the provided handler. The handler is expected to block +// until the connection is fully drained (e.g. handleConnection). +func dialAndHandle(ctx context.Context, info *discovery.ClientInfo, handle func(*websocket.Conn)) error { + url := clientInfoURL(info) + dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second} + + conn, _, err := dialer.DialContext(ctx, url, nil) + if err != nil { + return fmt.Errorf("dial %s: %w", url, err) + } + log.Printf("Dialed discovered client %s at %s", info.Name, url) + handle(conn) + return nil +} +``` + +Consolidated import block for `client_dialer.go`: + +```go +import ( + "context" + "fmt" + "log" + "strings" + "sync" + "time" + + "github.com/Sendspin/sendspin-go/internal/discovery" + "github.com/gorilla/websocket" +) +``` + +- [ ] **Step 3: Wire the dialer into `Server.Start()`** + +In `pkg/sendspin/server.go`, inside `Start()`, directly after the existing mDNS advertisement block (currently `server.go:172-185`), add: + +```go +if s.config.DiscoverClients { + if s.mdnsManager == nil { + // If mDNS isn't running for advertising, start a manager just for browsing. + s.mdnsManager = discovery.NewManager(discovery.Config{ + ServiceName: s.config.Name, + Port: s.config.Port, + ServerMode: true, + }) + } + + if err := s.mdnsManager.BrowseClients(); err != nil { + log.Printf("Failed to start client discovery: %v", err) + } else { + log.Printf("Browsing for clients advertising _sendspin._tcp") + + dialCtx, cancel := context.WithCancel(context.Background()) + s.dialerCancel = cancel + + dialer := newClientDialer(s.mdnsManager.Clients(), func(ctx context.Context, info *discovery.ClientInfo) error { + return dialAndHandle(ctx, info, s.handleConnection) + }) + + s.wg.Add(1) + go func() { + defer s.wg.Done() + dialer.run(dialCtx) + }() + } +} +``` + +Add `"context"` to the `pkg/sendspin/server.go` imports if it isn't already present. + +- [ ] **Step 4: Wire the dialer shutdown into `Server.Stop()` / shutdown path** + +Find the shutdown sequence in `pkg/sendspin/server.go` (look for `s.mdnsManager.Stop()`). Directly before the existing `if s.mdnsManager != nil { s.mdnsManager.Stop() }` call, add: + +```go +if s.dialerCancel != nil { + s.dialerCancel() +} +``` + +This guarantees in-flight dials see context cancellation before the mDNS manager stops emitting. + +- [ ] **Step 5: Run full test suite to verify nothing broke** + +Run: `go test ./...` +Expected: PASS. No new tests in this task — the new wiring is exercised by the integration test in Task 9. + +- [ ] **Step 6: Commit** + +```bash +git add pkg/sendspin/client_dialer.go pkg/sendspin/server.go +git commit -m "feat(sendspin): wire ClientDialer into server start/stop" +``` + +--- + +## Task 8: CLI Flag + +**Files:** +- Modify: `cmd/sendspin-server/main.go` + +- [ ] **Step 1: Add `-discover-clients` flag** + +In `cmd/sendspin-server/main.go`, add alongside the existing flag declarations (currently lines 20-26): + +```go +discoverClients = flag.Bool("discover-clients", false, + "Enable server-initiated discovery: browse _sendspin._tcp and dial out to clients") +``` + +- [ ] **Step 2: Pass the flag into `ServerConfig`** + +Modify the `sendspin.NewServer(sendspin.ServerConfig{...})` call (currently lines 73-79) to include the new field: + +```go +srv, err := sendspin.NewServer(sendspin.ServerConfig{ + Port: *port, + Name: serverName, + Source: source, + EnableMDNS: !*noMDNS, + Debug: *debug, + DiscoverClients: *discoverClients, +}) +``` + +- [ ] **Step 3: Build to verify the CLI compiles** + +Run: `make server` +Expected: build succeeds, `bin/sendspin-server` (or equivalent) produced. + +- [ ] **Step 4: Smoke test the flag is wired** + +Run: `./bin/sendspin-server -h 2>&1 | grep discover-clients` +Expected: output shows `-discover-clients` flag line. + +- [ ] **Step 5: Commit** + +```bash +git add cmd/sendspin-server/main.go +git commit -m "feat(cli): add -discover-clients flag to sendspin-server" +``` + +--- + +## Task 9: End-to-End Integration Test + +**Files:** +- Create: `pkg/sendspin/client_discovery_integration_test.go` + +This test spins up a fake mDNS advertiser for `_sendspin._tcp` pointing at a `httptest.Server` that upgrades to WebSocket and sends a `client/hello`. With `DiscoverClients: true`, the server should dial out, complete the handshake, and register the player in `s.clients`. + +- [ ] **Step 1: Write failing end-to-end test** + +```go +// pkg/sendspin/client_discovery_integration_test.go +// ABOUTME: Integration test for server-initiated client discovery +// ABOUTME: Fakes an mDNS advertisement and verifies the server dials + handshakes + +package sendspin + +import ( + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "github.com/Sendspin/sendspin-go/pkg/protocol" + "github.com/gorilla/websocket" + "github.com/hashicorp/mdns" +) + +func TestServerDiscoversAndDialsClient(t *testing.T) { + // 1. Stand up a fake "player" HTTP server that upgrades to WS and + // sends client/hello. + upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} + playerReady := make(chan string, 1) // receives serverID via server/hello + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Errorf("upgrade: %v", err) + return + } + defer conn.Close() + + hello := protocol.Message{ + Type: "client/hello", + Payload: protocol.ClientHello{ + ClientID: "test-player-1", + Name: "Test Player", + SupportedRoles: []string{"player@v1"}, + }, + } + data, _ := json.Marshal(hello) + if err := conn.WriteMessage(websocket.TextMessage, data); err != nil { + t.Errorf("write hello: %v", err) + return + } + + _, resp, err := conn.ReadMessage() + if err != nil { + return + } + var msg protocol.Message + if err := json.Unmarshal(resp, &msg); err != nil { + return + } + if msg.Type == "server/hello" { + playerReady <- "ok" + } + + // Block until the server closes us + for { + if _, _, err := conn.ReadMessage(); err != nil { + return + } + } + })) + defer ts.Close() + + _, portStr, err := net.SplitHostPort(strings.TrimPrefix(ts.URL, "http://")) + if err != nil { + t.Fatalf("split host: %v", err) + } + port, _ := strconv.Atoi(portStr) + + // 2. Advertise an mDNS _sendspin._tcp record pointing at httptest. + ips := []net.IP{net.ParseIP("127.0.0.1")} + svc, err := mdns.NewMDNSService( + "test-player-instance", + "_sendspin._tcp", + "", + "", + port, + ips, + []string{"path=/", "name=Test Player"}, + ) + if err != nil { + t.Fatalf("mdns service: %v", err) + } + mdnsServer, err := mdns.NewServer(&mdns.Config{Zone: svc}) + if err != nil { + t.Fatalf("mdns server: %v", err) + } + defer mdnsServer.Shutdown() + + // 3. Start a Sendspin server with DiscoverClients=true. + source := NewTestTone(48000, 2) + srv, err := NewServer(ServerConfig{ + Port: findFreePort(t), + Name: "Test Server", + Source: source, + EnableMDNS: false, + DiscoverClients: true, + }) + if err != nil { + t.Fatalf("NewServer: %v", err) + } + + serverDone := make(chan error, 1) + go func() { serverDone <- srv.Start() }() + defer srv.Stop() + + // 4. Wait for the player to confirm it received server/hello. + select { + case <-playerReady: + // success + case <-time.After(10 * time.Second): + t.Fatal("timed out waiting for dialed handshake") + } +} + +// findFreePort returns a TCP port that is free at call time. +func findFreePort(t *testing.T) int { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer l.Close() + return l.Addr().(*net.TCPAddr).Port +} + +``` + +Note: `NewTestTone` is the existing public test fixture used throughout `pkg/sendspin/server_test.go` (see e.g. line 16). It implements `AudioSource` with a sine test tone and is the same helper the other integration tests use. + +- [ ] **Step 2: Run the test to verify it fails if wiring is broken** + +Run: `go test ./pkg/sendspin/ -run TestServerDiscoversAndDialsClient -v -timeout 30s` +Expected (if previous tasks are implemented correctly): PASS. If it fails, the failure points at real wiring bugs — fix them in the appropriate earlier task and re-run. + +If mDNS doesn't work reliably in the CI sandbox (some environments block multicast), mark the test with `t.Skip("requires multicast")` behind an env var gate like `if os.Getenv("SENDSPIN_MULTICAST_TESTS") == "" { t.Skip(...) }`. Leave the gate off by default so local developers running `go test ./...` aren't blocked by environment issues. + +- [ ] **Step 3: Run the full suite** + +Run: `go test ./... -timeout 60s` +Expected: PASS. + +- [ ] **Step 4: Run lint** + +Run: `make lint` +Expected: no new warnings. + +- [ ] **Step 5: Commit** + +```bash +git add pkg/sendspin/client_discovery_integration_test.go +git commit -m "test(sendspin): end-to-end test for server-initiated discovery" +``` + +--- + +## Task 10: Documentation Update + +**Files:** +- Modify: `README.md` (if it mentions server modes) +- Modify: `pkg/sendspin/doc.go` (package doc) + +- [ ] **Step 1: Add a short section to `pkg/sendspin/doc.go`** + +Locate the existing package doc and append a paragraph: + +```go +// Server-Initiated Discovery +// +// When ServerConfig.DiscoverClients is true, the server browses mDNS +// for clients advertising _sendspin._tcp.local. and dials each one +// using the advertised path (TXT "path=", default "/sendspin"). The +// dialed connection is handed to the same handleConnection funnel +// inbound clients use, so the protocol handshake and audio streaming +// paths are unchanged. Per the Sendspin spec, a player must either +// advertise _sendspin._tcp OR connect directly — never both. +``` + +- [ ] **Step 2: Update `README.md` if it lists server flags** + +Check: `grep -n "no-mdns\|EnableMDNS" README.md` +If matches found, add a line mentioning `-discover-clients` in the same section. +If no matches, skip. + +- [ ] **Step 3: Commit** + +```bash +git add pkg/sendspin/doc.go README.md +git commit -m "docs: document server-initiated client discovery" +``` + +--- + +## Done Criteria + +- [ ] `go test ./...` passes +- [ ] `make lint` clean +- [ ] `make server` builds +- [ ] `./bin/sendspin-server -discover-clients` starts and logs `Browsing for clients advertising _sendspin._tcp` +- [ ] Integration test confirms a fake mDNS advertisement triggers a real WebSocket dial and handshake +- [ ] `handleConnection` in `pkg/sendspin/server.go` is *unmodified* by this plan — grep the final diff and confirm diff --git a/third_party/sendspin-go/docs/superpowers/plans/2026-04-20-server-config-yaml.md b/third_party/sendspin-go/docs/superpowers/plans/2026-04-20-server-config-yaml.md new file mode 100644 index 0000000..17f6e6f --- /dev/null +++ b/third_party/sendspin-go/docs/superpowers/plans/2026-04-20-server-config-yaml.md @@ -0,0 +1,1278 @@ +# sendspin-server YAML config + --daemon 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:** Give `sendspin-server` a YAML config file, `SENDSPIN_SERVER_*` env overlay, and a `--daemon` flag, plus the distribution artifacts needed to run it under systemd. Parity with `sendspin-player`. + +**Architecture:** Refactor `pkg/sendspin/config.go` so the generic machinery (flag overlay, YAML load, config-dir lookup) takes `(envPrefix, map[string]string)` instead of a typed `*PlayerConfigFile`. Add a parallel `ServerConfigFile` / `LoadServerConfig` surface using the shared helpers. Wire the server CLI to load it. Ship `sendspin-server.service`, `sendspin-server.env`, and `server.example.yaml`; split `install-daemon` into per-binary leaf targets. + +**Tech Stack:** Go 1.24+, `gopkg.in/yaml.v3`, Go stdlib `flag` package, systemd unit files, GNU Make. + +**Spec:** `docs/superpowers/specs/2026-04-20-server-config-yaml-design.md` + +**Environment note (from `memory/reference_cgo_toolchain.md`):** on Windows/MSYS2, prepend `/c/msys64/mingw64/bin` to `PATH` before any full-module build or test (`make`, `go build ./...`, `go test ./...`). The `pkg/sendspin` tests alone usually work without it — scope test runs narrowly unless told otherwise. + +**Flaky test:** `TestServerStartStop` has been flaky on port 8929 on Chris's box (`memory/project_test_port_8929.md`) — not a regression. If it fails in the final verification step, skip it and move on. + +--- + +## File Map + +**Modify:** +- `pkg/sendspin/config.go` — refactor `ApplyEnvAndFile` signature; extract helpers; add server surface. +- `pkg/sendspin/config_test.go` — update existing call-sites for the new signature; add server tests. +- `main.go` (player, repo root) — single-line call-site update. +- `cmd/sendspin-server/main.go` — add `--config` and `--daemon` flags, wire config loading, daemon-mode logging branch. +- `Makefile` — split install/uninstall targets. +- `README.md` — add Server config-file section + daemon pointer. + +**Create:** +- `dist/systemd/sendspin-server.service` +- `dist/systemd/sendspin-server.env` +- `dist/config/server.example.yaml` + +--- + +### Task 1: Refactor `ApplyEnvAndFile` to take `(envPrefix, fileValues)` instead of a typed struct + +**Files:** +- Modify: `pkg/sendspin/config.go:105-127` +- Modify: `pkg/sendspin/config_test.go:151, 186, 204, 219` +- Modify: `main.go:68` (player repo-root) + +This is a pure signature refactor — no new behavior. All changes land in one commit because they have to compile together. + +- [ ] **Step 1: Update the function signature and body** + +Replace the existing `ApplyEnvAndFile` in `pkg/sendspin/config.go` with: + +```go +// ApplyEnvAndFile overlays env vars and YAML config-file values +// into the given FlagSet, but only for flags the user did NOT set on the CLI. +// Precedence: CLI (untouched here) > env > file > flag default. +// +// envPrefix is the namespace for env-var lookups (e.g. "SENDSPIN_PLAYER_"). +// fileValues is the flat flag-key → value map the caller derives from its +// typed config struct (see PlayerConfigFile.asStringMap and +// ServerConfigFile.asStringMap). A nil map is treated as empty. +// +// setByUser is typically built with flag.Visit before calling this. +func ApplyEnvAndFile(fs *flag.FlagSet, setByUser map[string]bool, envPrefix string, fileValues map[string]string) error { + var firstErr error + fs.VisitAll(func(f *flag.Flag) { + if firstErr != nil || setByUser[f.Name] { + return + } + envKey := envPrefix + strings.ToUpper(strings.ReplaceAll(f.Name, "-", "_")) + if val, ok := os.LookupEnv(envKey); ok { + if err := fs.Set(f.Name, val); err != nil { + firstErr = fmt.Errorf("env %s -> -%s: %w", envKey, f.Name, err) + } + return + } + configKey := strings.ReplaceAll(f.Name, "-", "_") + if val, ok := fileValues[configKey]; ok { + if err := fs.Set(f.Name, val); err != nil { + firstErr = fmt.Errorf("config %s -> -%s: %w", configKey, f.Name, err) + } + } + }) + return firstErr +} +``` + +- [ ] **Step 2: Update player call-site in `main.go`** + +Find the line (around `main.go:68`): + +```go +if err := sendspin.ApplyEnvAndFile(flag.CommandLine, setByUser, cfg); err != nil { +``` + +Replace with: + +```go +if err := sendspin.ApplyEnvAndFile(flag.CommandLine, setByUser, sendspin.PlayerEnvPrefix, cfg.asStringMap()); err != nil { +``` + +Wait — `asStringMap` is unexported and lives in package `sendspin`. From `main.go` (package `main`), we can't call it directly. We need either (a) export it, or (b) have the player pass the raw struct through a small exported wrapper. + +Choose (a) — rename `asStringMap` to `AsStringMap` on `*PlayerConfigFile` and (in a later task) on `*ServerConfigFile`. Make this rename part of Step 1: update the method definition in `config.go:132` and every reference in `config_test.go`. + +So replace the block above with two edits: + +In `pkg/sendspin/config.go:132`: + +```go +// Before +func (c *PlayerConfigFile) asStringMap() map[string]string { + +// After +func (c *PlayerConfigFile) AsStringMap() map[string]string { +``` + +In `main.go` (player): + +```go +if err := sendspin.ApplyEnvAndFile(flag.CommandLine, setByUser, sendspin.PlayerEnvPrefix, cfg.AsStringMap()); err != nil { +``` + +- [ ] **Step 3: Update the four existing test call-sites in `config_test.go`** + +Line 151 (`TestApplyEnvAndFile_FileFillsUnsetFlags`): + +```go +// Before +if err := ApplyEnvAndFile(fs, setByUser, cfg); err != nil { + +// After +if err := ApplyEnvAndFile(fs, setByUser, PlayerEnvPrefix, cfg.AsStringMap()); err != nil { +``` + +Line 186 (`TestApplyEnvAndFile_EnvBeatsFile`): + +```go +// Before +if err := ApplyEnvAndFile(fs, map[string]bool{}, cfg); err != nil { + +// After +if err := ApplyEnvAndFile(fs, map[string]bool{}, PlayerEnvPrefix, cfg.AsStringMap()); err != nil { +``` + +Line 204 (`TestApplyEnvAndFile_NilConfigStillHonorsEnv`): + +```go +// Before +if err := ApplyEnvAndFile(fs, map[string]bool{}, nil); err != nil { + +// After +if err := ApplyEnvAndFile(fs, map[string]bool{}, PlayerEnvPrefix, nil); err != nil { +``` + +Line 219 (`TestApplyEnvAndFile_InvalidEnvReturnsError`): + +```go +// Before +err := ApplyEnvAndFile(fs, map[string]bool{}, nil) + +// After +err := ApplyEnvAndFile(fs, map[string]bool{}, PlayerEnvPrefix, nil) +``` + +- [ ] **Step 4: Run the package tests and verify they still pass** + +```bash +go test ./pkg/sendspin/ -run 'TestLoadPlayerConfig|TestApplyEnvAndFile' -v +``` + +Expected: all 5 tests PASS (`TestLoadPlayerConfig_ExplicitPathWithAllKeys`, `..._MissingFileIsNotAnError`, `..._InvalidYAMLIsAnError`, `..._EnvPathHonored`, and the four `TestApplyEnvAndFile_*` plus `TestWriteStringKey_*`). + +If cgo build errors appear (e.g., miniaudio), prepend `/c/msys64/mingw64/bin` to `PATH` per the memory note and retry. + +- [ ] **Step 5: Build the player to catch any lingering call-site issues** + +```bash +go build -o /dev/null . +``` + +Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add pkg/sendspin/config.go pkg/sendspin/config_test.go main.go +git commit -m "refactor(sendspin): ApplyEnvAndFile takes envPrefix+map, not typed struct + +Prepares the way for a second consumer (sendspin-server) to share the +same flag-overlay machinery. Player call-site passes cfg.AsStringMap() +and sendspin.PlayerEnvPrefix explicitly. asStringMap renamed to +AsStringMap so callers outside pkg/sendspin can use it. + +No behavior change. All existing tests pass unchanged except for +signature updates at four call-sites." +``` + +--- + +### Task 2: Extract `loadYAMLConfig` helper + +**Files:** +- Modify: `pkg/sendspin/config.go` + +Pull the generic "search paths, open first existing, YAML-unmarshal into `out`" logic out of `LoadPlayerConfig` so the upcoming `LoadServerConfig` can call it too. Pure refactor. + +- [ ] **Step 1: Add the helper function** + +Add above `LoadPlayerConfig`: + +```go +// loadYAMLConfig walks searchPaths, and for the first one that exists opens +// the file and unmarshals into out. It returns the path that was loaded, or +// empty if no candidate existed. A missing file is not an error; I/O or +// parse errors are returned as-is with the offending path attached. +func loadYAMLConfig(searchPaths []string, out any) (string, error) { + for _, candidate := range searchPaths { + if candidate == "" { + continue + } + data, err := os.ReadFile(candidate) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + return candidate, fmt.Errorf("read %s: %w", candidate, err) + } + if err := yaml.Unmarshal(data, out); err != nil { + return candidate, fmt.Errorf("parse %s: %w", candidate, err) + } + return candidate, nil + } + return "", nil +} +``` + +- [ ] **Step 2: Refactor `LoadPlayerConfig` to use it** + +Replace the body: + +```go +func LoadPlayerConfig(explicitPath string) (*PlayerConfigFile, string, error) { + var cfg PlayerConfigFile + used, err := loadYAMLConfig(playerConfigSearchPaths(explicitPath), &cfg) + if err != nil { + return nil, used, err + } + if used == "" { + return nil, "", nil + } + return &cfg, used, nil +} +``` + +- [ ] **Step 3: Run the package tests** + +```bash +go test ./pkg/sendspin/ -run 'TestLoadPlayerConfig' -v +``` + +Expected: all `TestLoadPlayerConfig_*` tests PASS. + +- [ ] **Step 4: Commit** + +```bash +git add pkg/sendspin/config.go +git commit -m "refactor(sendspin): extract loadYAMLConfig helper + +Prepares for a second consumer. No behavior change." +``` + +--- + +### Task 3: Extract `userConfigPath` helper + +**Files:** +- Modify: `pkg/sendspin/config.go` + +- [ ] **Step 1: Add the helper** + +Add above `DefaultPlayerConfigPath`: + +```go +// userConfigPath returns /sendspin/. Matches the +// canonical path layout for both player.yaml and server.yaml. +func userConfigPath(relative string) (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("user config dir: %w", err) + } + return filepath.Join(dir, "sendspin", relative), nil +} +``` + +- [ ] **Step 2: Refactor `DefaultPlayerConfigPath` and `playerConfigSearchPaths`** + +Replace `DefaultPlayerConfigPath`: + +```go +func DefaultPlayerConfigPath() (string, error) { + return userConfigPath("player.yaml") +} +``` + +In `playerConfigSearchPaths`, replace the `os.UserConfigDir()` block with: + +```go +if p, err := userConfigPath("player.yaml"); err == nil { + paths = append(paths, p) +} +``` + +- [ ] **Step 3: Run the package tests** + +```bash +go test ./pkg/sendspin/ -v +``` + +Expected: all tests in the package PASS. + +- [ ] **Step 4: Commit** + +```bash +git add pkg/sendspin/config.go +git commit -m "refactor(sendspin): extract userConfigPath helper + +Shared between DefaultPlayerConfigPath and playerConfigSearchPaths; +ready for the server's analogous use. No behavior change." +``` + +--- + +### Task 4: Add `ServerConfigFile` + `LoadServerConfig` with TDD tests + +**Files:** +- Modify: `pkg/sendspin/config.go` — add server surface. +- Create: `pkg/sendspin/config_server_test.go` — new test file, keeps server tests adjacent to player tests but separately readable. + +TDD: write the failing tests first, then the implementation. + +- [ ] **Step 1: Write the failing tests** + +Create `pkg/sendspin/config_server_test.go`: + +```go +// ABOUTME: Tests for YAML config loading and env prefix routing for sendspin-server +package sendspin + +import ( + "flag" + "os" + "path/filepath" + "testing" +) + +func TestLoadServerConfig_ExplicitPathWithAllKeys(t *testing.T) { + path := filepath.Join(t.TempDir(), "server.yaml") + body := `# Test server +name: "Living Room Server" +port: 9000 +log_file: "custom-server.log" +debug: true +no_mdns: true +no_tui: true +audio: "/srv/music/radio.m3u8" +discover_clients: true +daemon: true +` + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + + cfg, used, err := LoadServerConfig(path) + if err != nil { + t.Fatalf("load: %v", err) + } + if used != path { + t.Errorf("used = %q, want %q", used, path) + } + if cfg == nil { + t.Fatal("cfg is nil") + } + if cfg.Name != "Living Room Server" { + t.Errorf("name = %q", cfg.Name) + } + if cfg.Port == nil || *cfg.Port != 9000 { + t.Errorf("port = %v, want 9000", cfg.Port) + } + if cfg.LogFile != "custom-server.log" { + t.Errorf("log_file = %q", cfg.LogFile) + } + if cfg.Debug == nil || !*cfg.Debug { + t.Errorf("debug = %v, want true", cfg.Debug) + } + if cfg.NoMDNS == nil || !*cfg.NoMDNS { + t.Errorf("no_mdns = %v, want true", cfg.NoMDNS) + } + if cfg.NoTUI == nil || !*cfg.NoTUI { + t.Errorf("no_tui = %v, want true", cfg.NoTUI) + } + if cfg.Audio != "/srv/music/radio.m3u8" { + t.Errorf("audio = %q", cfg.Audio) + } + if cfg.DiscoverClients == nil || !*cfg.DiscoverClients { + t.Errorf("discover_clients = %v, want true", cfg.DiscoverClients) + } + if cfg.Daemon == nil || !*cfg.Daemon { + t.Errorf("daemon = %v, want true", cfg.Daemon) + } +} + +func TestLoadServerConfig_MissingFileIsNotAnError(t *testing.T) { + cfg, used, err := LoadServerConfig(filepath.Join(t.TempDir(), "does-not-exist.yaml")) + if err != nil { + t.Fatalf("load: %v", err) + } + if cfg != nil || used != "" { + t.Errorf("expected nil/empty for missing file, got cfg=%v path=%q", cfg, used) + } +} + +func TestLoadServerConfig_EnvPathHonored(t *testing.T) { + dir := t.TempDir() + envPath := filepath.Join(dir, "from-env-server.yaml") + if err := os.WriteFile(envPath, []byte("name: EnvServer\n"), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + t.Setenv("SENDSPIN_SERVER_CONFIG", envPath) + + cfg, used, err := LoadServerConfig("") + if err != nil { + t.Fatalf("load: %v", err) + } + if used != envPath { + t.Errorf("used = %q, want %q", used, envPath) + } + if cfg.Name != "EnvServer" { + t.Errorf("name = %q", cfg.Name) + } +} + +// TestApplyEnvAndFile_ServerEnvPrefix confirms the generalized envPrefix +// parameter routes SENDSPIN_SERVER_* correctly. Precedence rules themselves +// are already covered by the player tests; this is pure plumbing. +func TestApplyEnvAndFile_ServerEnvPrefix(t *testing.T) { + fs := flag.NewFlagSet("server", flag.ContinueOnError) + port := fs.Int("port", 8927, "port") + audio := fs.String("audio", "", "audio source") + if err := fs.Parse(nil); err != nil { + t.Fatalf("parse: %v", err) + } + t.Setenv("SENDSPIN_SERVER_PORT", "9999") + t.Setenv("SENDSPIN_SERVER_AUDIO", "/srv/env.flac") + + if err := ApplyEnvAndFile(fs, map[string]bool{}, ServerEnvPrefix, nil); err != nil { + t.Fatalf("apply: %v", err) + } + if *port != 9999 { + t.Errorf("port = %d, want 9999", *port) + } + if *audio != "/srv/env.flac" { + t.Errorf("audio = %q", *audio) + } +} +``` + +- [ ] **Step 2: Run the tests — confirm they fail to compile** + +```bash +go test ./pkg/sendspin/ -run 'TestLoadServerConfig|TestApplyEnvAndFile_ServerEnvPrefix' -v +``` + +Expected: compile error — `undefined: LoadServerConfig`, `undefined: ServerEnvPrefix`, `undefined: ServerConfigFile` (fields). + +- [ ] **Step 3: Add the server surface to `config.go`** + +Append to `pkg/sendspin/config.go`: + +```go +// ServerEnvPrefix is the namespace for environment overrides of server +// config values. Env key = ServerEnvPrefix + upper-snake(flag name). +// Example: "-no-mdns" -> SENDSPIN_SERVER_NO_MDNS. +const ServerEnvPrefix = "SENDSPIN_SERVER_" + +// ServerConfigFile mirrors the server's CLI flags. Fields with "zero" values +// that could reasonably be meaningful (bool) are pointers so absence in +// the YAML can be distinguished from an explicit false. +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"` +} + +// LoadServerConfig searches for a server.yaml and returns its parsed contents +// along with the path that was loaded (empty if none was found). +// +// Search order (first existing wins): +// 1. explicitPath if non-empty +// 2. $SENDSPIN_SERVER_CONFIG if set +// 3. $XDG_CONFIG_HOME or OS equivalent + /sendspin/server.yaml +// 4. /etc/sendspin/server.yaml +// +// A missing file is not an error; the caller gets (nil, "", nil). +func LoadServerConfig(explicitPath string) (*ServerConfigFile, string, error) { + var cfg ServerConfigFile + used, err := loadYAMLConfig(serverConfigSearchPaths(explicitPath), &cfg) + if err != nil { + return nil, used, err + } + if used == "" { + return nil, "", nil + } + return &cfg, used, nil +} + +// DefaultServerConfigPath returns the canonical user-level server.yaml path +// for this OS. +func DefaultServerConfigPath() (string, error) { + return userConfigPath("server.yaml") +} + +func serverConfigSearchPaths(explicit string) []string { + paths := make([]string, 0, 4) + if explicit != "" { + paths = append(paths, explicit) + } + if env := os.Getenv("SENDSPIN_SERVER_CONFIG"); env != "" { + paths = append(paths, env) + } + if p, err := userConfigPath("server.yaml"); err == nil { + paths = append(paths, p) + } + paths = append(paths, "/etc/sendspin/server.yaml") + return paths +} + +// AsStringMap returns only the keys the user actually set in the YAML, as +// strings suitable for flag.Set. Absent keys are omitted so the overlay +// correctly falls through to the flag default. +func (c *ServerConfigFile) AsStringMap() map[string]string { + m := make(map[string]string) + if c == nil { + return m + } + if c.Name != "" { + m["name"] = c.Name + } + if c.Port != nil { + m["port"] = strconv.Itoa(*c.Port) + } + if c.LogFile != "" { + m["log_file"] = c.LogFile + } + if c.Debug != nil { + m["debug"] = strconv.FormatBool(*c.Debug) + } + if c.NoMDNS != nil { + m["no_mdns"] = strconv.FormatBool(*c.NoMDNS) + } + if c.NoTUI != nil { + m["no_tui"] = strconv.FormatBool(*c.NoTUI) + } + if c.Audio != "" { + m["audio"] = c.Audio + } + if c.DiscoverClients != nil { + m["discover_clients"] = strconv.FormatBool(*c.DiscoverClients) + } + if c.Daemon != nil { + m["daemon"] = strconv.FormatBool(*c.Daemon) + } + return m +} +``` + +- [ ] **Step 4: Run the server tests — confirm they pass** + +```bash +go test ./pkg/sendspin/ -run 'TestLoadServerConfig|TestApplyEnvAndFile_ServerEnvPrefix' -v +``` + +Expected: all 4 tests PASS. + +- [ ] **Step 5: Run the full package test suite to confirm no regressions** + +```bash +go test ./pkg/sendspin/ -v +``` + +Expected: every test in the package PASSES. + +- [ ] **Step 6: Commit** + +```bash +git add pkg/sendspin/config.go pkg/sendspin/config_server_test.go +git commit -m "feat(sendspin): ServerConfigFile + LoadServerConfig + +Mirrors LoadPlayerConfig: CLI > env > ~/.config/sendspin/server.yaml > +/etc/sendspin/server.yaml. Pointer bools so absence in YAML is distinct +from explicit false. AsStringMap() feeds the shared ApplyEnvAndFile +helper with SENDSPIN_SERVER_ as the env prefix." +``` + +--- + +### Task 5: Wire `--config` and `--daemon` into `cmd/sendspin-server/main.go` + +**Files:** +- Modify: `cmd/sendspin-server/main.go` + +This is the single user-visible change to the server binary. Touches flag declarations, logging setup, and the config-overlay call site. + +- [ ] **Step 1: Add the two new flag declarations** + +In the `var (...)` block at the top, add (order matches the player's convention — `config` first, `daemon` near `no-tui`): + +```go +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.") +) +``` + +- [ ] **Step 2: Wire config loading after `flag.Parse()`** + +Replace the opening of `main()` (current lines 30-33) with: + +```go +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) +``` + +(`cfg.AsStringMap()` is safe on a nil `cfg` — returns an empty map.) + +- [ ] **Step 3: Replace the logging-setup block with a daemon-aware branch** + +Current block (around lines 35-46) opens the log file unconditionally. Replace with: + +```go + 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)) + } + } +``` + +- [ ] **Step 4: Add the daemon-mode banner line** + +Find the existing non-TUI startup log (around line 57-59): + +```go + if !useTUI { + log.Printf("Starting Sendspin Server: %s on port %d", serverName, *port) + } +``` + +Replace with: + +```go + if !useTUI { + log.Printf("Starting Sendspin Server: %s on port %d", serverName, *port) + if *daemon { + log.Printf("Daemon mode: logging to stdout only") + } + } +``` + +- [ ] **Step 5: Build the server binary** + +```bash +make server +``` + +Expected: builds cleanly. If cgo errors appear, prepend `/c/msys64/mingw64/bin` to `PATH` first. + +- [ ] **Step 6: Smoke test 1 — default (no config, no daemon)** + +```bash +./sendspin-server --no-tui +``` + +Expected: "Starting Sendspin Server: ... on port 8927" in stdout; `sendspin-server.log` appears in cwd; mDNS advertisement starts. Ctrl+C to exit. + +- [ ] **Step 7: Smoke test 2 — config file overrides port** + +Create `/tmp/s.yaml` (or equivalent) with: +```yaml +port: 9000 +``` + +Run: +```bash +./sendspin-server --no-tui --config /tmp/s.yaml +``` + +Expected: banner says `port 9000`. Ctrl+C. + +- [ ] **Step 8: Smoke test 3 — env beats file** + +```bash +SENDSPIN_SERVER_PORT=9001 ./sendspin-server --no-tui --config /tmp/s.yaml +``` + +Expected: banner says `port 9001`. + +- [ ] **Step 9: Smoke test 4 — CLI beats env + file** + +```bash +SENDSPIN_SERVER_PORT=9001 ./sendspin-server --no-tui --port 9002 --config /tmp/s.yaml +``` + +Expected: banner says `port 9002`. + +- [ ] **Step 10: Smoke test 5 — daemon mode** + +Delete any stale `sendspin-server.log` in cwd first. Then: + +```bash +./sendspin-server --daemon +``` + +Expected: logs to stdout (no TUI), banner includes "Daemon mode: logging to stdout only", no `sendspin-server.log` file created. Ctrl+C to exit, then: + +```bash +ls sendspin-server.log 2>&1 || echo "confirmed: no log file created" +``` + +- [ ] **Step 11: Commit** + +```bash +git add cmd/sendspin-server/main.go +git commit -m "feat(server): --config and --daemon flags + +Matches the player's config surface: YAML search (CLI > env > +~/.config/sendspin/server.yaml > /etc/sendspin/server.yaml), env +overlay via SENDSPIN_SERVER_*, and --daemon (stdout-only logging, +no TUI, no log file) for journalctl-friendly operation under systemd." +``` + +--- + +### Task 6: Create `dist/systemd/sendspin-server.service` + +**Files:** +- Create: `dist/systemd/sendspin-server.service` + +- [ ] **Step 1: Write the unit file** + +```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 +# Pass CLI flags via SENDSPIN_SERVER_OPTS in the environment file. +# ExecStart is overridden below to append them. +ExecStart= +ExecStart=/usr/local/bin/sendspin-server --daemon $SENDSPIN_SERVER_OPTS + +# Hardening +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=read-only +PrivateTmp=true + +[Install] +WantedBy=multi-user.target +``` + +- [ ] **Step 2: Commit** + +```bash +git add dist/systemd/sendspin-server.service +git commit -m "feat(dist): systemd unit for sendspin-server + +Mirrors sendspin-player.service. ProtectHome=read-only (not =yes) so +operators can use --audio /home/user/Music/... under systemd without +fighting the sandbox." +``` + +--- + +### Task 7: Create `dist/systemd/sendspin-server.env` + +**Files:** +- Create: `dist/systemd/sendspin-server.env` + +- [ ] **Step 1: Write the env file** + +```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="" +``` + +- [ ] **Step 2: Commit** + +```bash +git add dist/systemd/sendspin-server.env +git commit -m "feat(dist): /etc/default/sendspin-server starter" +``` + +--- + +### Task 8: Create `dist/config/server.example.yaml` + +**Files:** +- Create: `dist/config/server.example.yaml` + +- [ ] **Step 1: Write the annotated example** + +```yaml +# sendspin-server configuration +# +# Search order (first existing file wins; missing is not an error): +# 1. --config +# 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_ env > this file > built-in default +# +# Every key is optional. Uncomment and adjust as needed. + +# --------------------------------------------------------------------------- +# Identity +# --------------------------------------------------------------------------- + +# Friendly name shown in Music Assistant and via mDNS. +# Default: -sendspin-server +# name: "Living Room Server" + +# --------------------------------------------------------------------------- +# Network +# --------------------------------------------------------------------------- + +# WebSocket server port. +# port: 8927 + +# Disable mDNS advertisement. Clients must then connect manually. +# no_mdns: false + +# Enable server-initiated discovery: browse _sendspin._tcp and dial out +# to clients. Useful for players that can't initiate connections. +# discover_clients: false + +# --------------------------------------------------------------------------- +# Audio source +# --------------------------------------------------------------------------- + +# Local file path, HTTP URL, or HLS URL. Empty = built-in test tone. +# Examples: +# audio: "/srv/music/ambient.flac" +# audio: "http://example.com/stream.mp3" +# audio: "https://stream.radiofrance.fr/fip/fip.m3u8" +# audio: "" + +# --------------------------------------------------------------------------- +# Runtime / logging +# --------------------------------------------------------------------------- + +# Daemon mode: log to stdout only (journalctl-friendly), no TUI, no log +# file. Recommended when running under systemd. +# daemon: false + +# Disable the interactive terminal UI, stream logs to stdout instead. +# no_tui: false + +# Log file path. Ignored when daemon mode is on. +# log_file: "sendspin-server.log" + +# Enable verbose debug logging. +# debug: false +``` + +- [ ] **Step 2: Commit** + +```bash +git add dist/config/server.example.yaml +git commit -m "docs(server): annotated server.yaml starter" +``` + +--- + +### Task 9: Split Makefile `install-daemon` / `uninstall-daemon` into leaves + aggregates + +**Files:** +- Modify: `Makefile` (lines ~1-6 phony list, and the block at 85-117) + +The existing `install-daemon` target (line 85) installs the player only. Rewrite so that `install-daemon` becomes an aggregate of per-binary leaf targets, and a matching `install-server-daemon` target installs the new artifacts. + +- [ ] **Step 1: Update the `.PHONY` declaration** + +At the top of the Makefile (around line 5), add the new target names: + +```make +.PHONY: all clean test test-verbose test-coverage test-race lint help \ + build-all build-linux build-darwin help conformance \ + install-daemon uninstall-daemon \ + install-player-daemon uninstall-player-daemon \ + install-server-daemon uninstall-server-daemon +``` + +(Exact existing variable names may differ; preserve the existing list and append the new targets.) + +- [ ] **Step 2: Rename the existing `install-daemon` body to `install-player-daemon`** + +Replace the current `install-daemon: player` stanza (lines 85-107) with: + +```make +# 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" +``` + +- [ ] **Step 3: Add the new `install-server-daemon` target** + +Append directly below: + +```make +# 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 +``` + +- [ ] **Step 4: Rename the existing `uninstall-daemon` body to `uninstall-player-daemon` and add the server counterpart + aggregate** + +Replace the current `uninstall-daemon` stanza (lines 110-117) with: + +```make +# 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 +``` + +- [ ] **Step 5: Update the `help` target** + +Find the help-text block (around line 168). Replace the single daemon line: + +```make + @echo " make install-daemon - Install as systemd service (Linux, requires root)" + @echo " make uninstall-daemon - Remove systemd service" +``` + +With: + +```make + @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" +``` + +- [ ] **Step 6: Sanity check — `make help` still parses** + +```bash +make help +``` + +Expected: help text lists the new targets; no Make parse errors. + +- [ ] **Step 7: Dry-run the new targets to confirm they parse** + +```bash +make -n install-server-daemon +make -n install-daemon +make -n uninstall-server-daemon +make -n uninstall-daemon +``` + +Expected: each prints the commands it *would* run (the `install`, `rm`, `systemctl` calls), without actually executing anything. If Make reports missing variables or syntax errors, fix them before committing. + +**Not run here:** actual `make install-daemon` execution requires root on a Linux box. Document in the final verification step for a Linux smoke test. + +- [ ] **Step 8: Commit** + +```bash +git add Makefile +git commit -m "build: split install-daemon into per-binary targets + +install-daemon now aggregates install-player-daemon and +install-server-daemon (same for uninstall). Operators can install or +remove either side alone; running 'make install-daemon' still installs +both, preserving existing behavior." +``` + +--- + +### Task 10: README — add Server config-file section and daemon pointer + +**Files:** +- Modify: `README.md` (around line 232, right after the "Server Options" list) + +- [ ] **Step 1: Add `--config` and `--daemon` to the Server Options list** + +Find the Server Options bullet list (around lines 222-232). After the existing `--no-tui` bullet at line 232, append: + +```markdown +- `--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`. +``` + +- [ ] **Step 2: Add a `Configuration File (server.yaml)` subsection after the Server TUI section** + +After the Server TUI block (around line 243, before the `### Player` heading at line 244), insert: + +```markdown +#### 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 ` 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_` 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`. +``` + +- [ ] **Step 3: Commit** + +```bash +git add README.md +git commit -m "docs: document server.yaml config and --daemon flag + +Mirrors the existing player config docs. Adds a brief 'Running under +systemd' subsection covering install-daemon and the split per-binary +targets." +``` + +--- + +### Task 11: Final verification + +No code changes — this is a verification pass. Run the full test suite and the full build to catch anything the per-task smoke tests missed. + +- [ ] **Step 1: Run the full test suite** + +```bash +go test ./... -short +``` + +Expected: all tests PASS. + +**Known flake:** `TestServerStartStop` on port 8929 is a pre-existing environment issue on Chris's box (not introduced by this plan). If it's the only failure, record it and move on — do not chase it. + +If cgo build errors appear, prepend `/c/msys64/mingw64/bin` to `PATH` and retry. + +- [ ] **Step 2: Run the linter** + +```bash +make lint +``` + +Expected: no lint failures. + +- [ ] **Step 3: Build both binaries** + +```bash +make +``` + +Expected: `sendspin-player` and `sendspin-server` built successfully. + +- [ ] **Step 4: End-to-end server smoke — default** + +```bash +./sendspin-server --no-tui +``` + +Expected: starts on port 8927, mDNS advertisement logs appear, Ctrl+C exits cleanly. + +- [ ] **Step 5: End-to-end server smoke — config + env + CLI precedence** + +Create `/tmp/s.yaml`: +```yaml +port: 9000 +``` + +Verify the three-layer precedence: + +```bash +./sendspin-server --no-tui --config /tmp/s.yaml +# Expected: port 9000 + +SENDSPIN_SERVER_PORT=9001 ./sendspin-server --no-tui --config /tmp/s.yaml +# Expected: port 9001 + +SENDSPIN_SERVER_PORT=9001 ./sendspin-server --no-tui --port 9002 --config /tmp/s.yaml +# Expected: port 9002 +``` + +- [ ] **Step 6: End-to-end server smoke — daemon mode** + +```bash +rm -f sendspin-server.log +./sendspin-server --daemon +# Ctrl+C +ls sendspin-server.log 2>&1 || echo "confirmed: no log file created" +``` + +Expected: no log file; stdout had the "Daemon mode: logging to stdout only" banner. + +- [ ] **Step 7 (Linux-only, manual): systemd install smoke** + +On a Linux box with the repo checked out: + +```bash +sudo make install-server-daemon +sudo systemctl enable --now sendspin-server +journalctl -u sendspin-server -f +# Expected: "Starting Sendspin Server: ..." and "Daemon mode: ..." lines +sudo systemctl stop sendspin-server +sudo make uninstall-server-daemon +# Expected: /etc/sendspin/server.yaml and /etc/default/sendspin-server remain; binary + unit removed +``` + +If you're not on Linux, note this step as pending and flag it in the PR description. + +- [ ] **Step 8: Player regression smoke** + +Because Task 1 touched the player call-site, confirm the player still starts and connects: + +```bash +./sendspin-player --no-tui --server ws://localhost:8927 +``` + +Expected: mDNS-skipped direct connect, normal startup output. Ctrl+C exits cleanly. + +- [ ] **Step 9: Final commit (only if anything was found and fixed)** + +If steps 1-8 revealed issues that needed fixes, commit them with a descriptive message. If everything passed, nothing to commit here. + +--- + +## Out-of-scope reminders + +- No `server_id` persistent identity — explicitly deferred in the spec. +- No README deep-dive on daemon operation beyond the brief pointer above. +- No refactor of `pkg/sendspin` into a `configfile` sub-package — YAGNI until a third consumer arrives. diff --git a/third_party/sendspin-go/docs/superpowers/plans/2026-05-01-quickstart-pi.md b/third_party/sendspin-go/docs/superpowers/plans/2026-05-01-quickstart-pi.md new file mode 100644 index 0000000..3f6b2ac --- /dev/null +++ b/third_party/sendspin-go/docs/superpowers/plans/2026-05-01-quickstart-pi.md @@ -0,0 +1,1012 @@ +# Pi Quickstart Script 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:** Ship `scripts/quickstart-pi.sh`, a self-contained `curl | sudo bash` installer that turns a fresh 64-bit Raspberry Pi OS Lite host into a running, mDNS-discoverable `sendspin-player` daemon using the prebuilt arm64 release tarball. + +**Architecture:** A single Bash script under `set -euo pipefail` with phase-named functions, fetched verbatim from `raw.githubusercontent.com`. Reuses every artifact already in the repo: the arm64 release tarball, `dist/systemd/sendspin-player.service`, `dist/systemd/sendspin-player.env`, and `dist/config/player.example.yaml` — all pulled at the resolved release tag's ref. No code changes inside `pkg/` or `cmd/`. Per-task verification is `shellcheck`; final acceptance is a smoke run inside an `arm64v8/debian:bookworm` Docker container plus a manual Pi install. + +**Tech Stack:** Bash 5.x, `curl`, `tar`, `apt-get`, `systemctl`, `shellcheck` (per-task verify), Docker with QEMU for arm64 emulation (final smoke test). No new runtime dependencies on the user's Pi beyond what `apt-get` installs. + +**Spec:** [docs/superpowers/specs/2026-05-01-quickstart-pi-design.md](../specs/2026-05-01-quickstart-pi-design.md) + +--- + +## File Structure + +- **Create:** `scripts/quickstart-pi.sh` — the entire installer (single file, all phases as functions, `main()` at the bottom) +- **Modify:** `.github/workflows/ci.yml` — add a shellcheck step to the existing `lint` job +- **Modify:** `README.md` — add a "Quickstart on Raspberry Pi" section near the existing install instructions + +The script lives in `scripts/` (a new directory) rather than the repo root because the root already hosts `install-deps.sh` (build-time deps, source-build users) — keeping the prebuilt-binary installer in `scripts/` makes the distinction explicit. + +Per-task commits use the `feat(quickstart):` scope on functional additions, `chore(ci):` for the lint step, and `docs(readme):` for the README change. + +**Prerequisite for the implementer:** install `shellcheck` locally (`brew install shellcheck` / `apt-get install shellcheck`). Every task ends with a `shellcheck` invocation. + +--- + +## Task 1: Script skeleton with stubbed phases and `--help` + +**Files:** +- Create: `scripts/quickstart-pi.sh` + +- [ ] **Step 1: Create the directory and write the skeleton** + +```bash +mkdir -p scripts +``` + +Write `scripts/quickstart-pi.sh`: + +```bash +#!/usr/bin/env bash +# ABOUTME: One-shot installer for sendspin-player on 64-bit Raspberry Pi OS. +# ABOUTME: Fetches the latest arm64 release tarball, installs systemd unit, starts daemon. + +set -euo pipefail + +readonly REPO_OWNER="Sendspin" +readonly REPO_NAME="sendspin-go" +readonly REPO_URL="https://github.com/${REPO_OWNER}/${REPO_NAME}" +readonly RAW_URL_BASE="https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}" +readonly BINARY_NAME="sendspin-player" +readonly INSTALL_PATH="/usr/local/bin/${BINARY_NAME}" +readonly UNIT_PATH="/etc/systemd/system/${BINARY_NAME}.service" +readonly ENV_PATH="/etc/default/${BINARY_NAME}" +readonly CONFIG_DIR="/etc/sendspin" +readonly CONFIG_PATH="${CONFIG_DIR}/player.yaml" + +# Set by parse_args +ARG_NAME="" +ARG_DEVICE="" +ARG_VERSION="" +ARG_UNINSTALL=0 + +# Resolved by resolve_version +RESOLVED_TAG="" +RESOLVED_REF="" + +usage() { + cat <] [--device ] [--version ] [--uninstall] + +Installs sendspin-player as a systemd daemon on 64-bit Raspberry Pi OS. + +Options: + --name Friendly player name (default: -sendspin-player). + --device Exact audio device name. Run 'sendspin-player --list-audio-devices' after + install to discover available names. + --version Pin to a specific release tag (e.g. v1.6.2). Default: latest. + --uninstall Stop the service and remove the binary and unit file. Config is preserved. + -h, --help Show this help. + +Run with sudo: + curl -fsSL ${RAW_URL_BASE}/main/scripts/quickstart-pi.sh | sudo bash + curl -fsSL ${RAW_URL_BASE}/main/scripts/quickstart-pi.sh | sudo bash -s -- --name "Living Room" +EOF +} + +log() { printf '==> %s\n' "$*"; } +warn() { printf 'WARN: %s\n' "$*" >&2; } +die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } + +parse_args() { :; } +preflight() { :; } +do_uninstall() { :; } +install_apt_deps() { :; } +resolve_version() { :; } +stop_service() { :; } +install_binary() { :; } +install_unit() { :; } +install_env() { :; } +install_config() { :; } +start_and_verify() { :; } + +main() { + parse_args "$@" + preflight + if [[ "${ARG_UNINSTALL}" -eq 1 ]]; then + do_uninstall + exit 0 + fi + install_apt_deps + resolve_version + stop_service + install_binary + install_unit + install_env + install_config + start_and_verify +} + +main "$@" +``` + +- [ ] **Step 2: Make executable and run shellcheck** + +```bash +chmod +x scripts/quickstart-pi.sh +shellcheck scripts/quickstart-pi.sh +``` + +Expected: clean exit 0, no warnings. + +- [ ] **Step 3: Verify `--help` works in isolation** + +```bash +bash scripts/quickstart-pi.sh --help 2>&1 | head -5 +``` + +Note: this will currently fail because `parse_args` is a no-op stub — it falls through to `preflight` which is also a no-op, then continues to `install_apt_deps` etc. That's expected; we're just verifying the syntax parses. Use `bash -n scripts/quickstart-pi.sh` to confirm syntax-only: + +```bash +bash -n scripts/quickstart-pi.sh +``` + +Expected: clean exit 0. + +- [ ] **Step 4: Commit** + +```bash +git add scripts/quickstart-pi.sh +git commit -m "feat(quickstart): script skeleton with stubbed phases" +``` + +--- + +## Task 2: Argument parser and `--help` handling + +**Files:** +- Modify: `scripts/quickstart-pi.sh` (replace `parse_args` stub) + +- [ ] **Step 1: Replace the `parse_args` stub** + +Find: +```bash +parse_args() { :; } +``` + +Replace with: +```bash +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --name) + [[ $# -ge 2 ]] || die "--name requires a value" + ARG_NAME="$2" + shift 2 + ;; + --device) + [[ $# -ge 2 ]] || die "--device requires a value" + ARG_DEVICE="$2" + shift 2 + ;; + --version) + [[ $# -ge 2 ]] || die "--version requires a value (e.g. v1.6.2)" + ARG_VERSION="$2" + shift 2 + ;; + --uninstall) + ARG_UNINSTALL=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + printf 'Unknown argument: %s\n\n' "$1" >&2 + usage >&2 + exit 2 + ;; + esac + done +} +``` + +- [ ] **Step 2: Run shellcheck** + +```bash +shellcheck scripts/quickstart-pi.sh +``` + +Expected: clean. + +- [ ] **Step 3: Smoke test the parser by hand** + +```bash +bash scripts/quickstart-pi.sh --help # prints usage, exits 0 +bash scripts/quickstart-pi.sh --bogus 2>&1 | head -3 # prints "Unknown argument" + usage, exits 2 +echo "exit=$?" +``` + +Expected: `--help` exits 0; `--bogus` exits 2. + +(Don't run it with valid args yet — preflight is still a stub and would let the script fall through to apt-get without arch-checking. That's the next task.) + +- [ ] **Step 4: Commit** + +```bash +git add scripts/quickstart-pi.sh +git commit -m "feat(quickstart): argument parser with --name/--device/--version/--uninstall" +``` + +--- + +## Task 3: Pre-flight checks (root, arch, debian, systemd) + +**Files:** +- Modify: `scripts/quickstart-pi.sh` (replace `preflight` stub) + +- [ ] **Step 1: Replace the `preflight` stub** + +Find: +```bash +preflight() { :; } +``` + +Replace with: +```bash +preflight() { + if [[ "${EUID}" -ne 0 ]]; then + die "Root required. Re-run with sudo: + curl -fsSL ${RAW_URL_BASE}/main/scripts/quickstart-pi.sh | sudo bash" + fi + + local arch + arch="$(uname -m)" + if [[ "${arch}" != "aarch64" ]]; then + die "Unsupported architecture: ${arch}. This script supports 64-bit +Raspberry Pi OS only (aarch64). For Pi 3 / 4 / 5 / Zero 2 W, install +the 64-bit Pi OS image: https://www.raspberrypi.com/software/operating-systems/ +Pi 1 / Zero (v1) / Zero W are not supported (32-bit ARMv6 only)." + fi + + if [[ ! -f /etc/debian_version ]]; then + die "Unsupported OS. This script targets Debian-based distros (Pi OS, +Raspberry Pi OS Lite). For other distros see the README install steps: + ${REPO_URL}#installation" + fi + + if ! command -v systemctl >/dev/null 2>&1; then + die "systemctl not found. The quickstart installs sendspin-player as a +systemd service; non-systemd hosts must follow the manual install steps." + fi +} +``` + +- [ ] **Step 2: Run shellcheck** + +```bash +shellcheck scripts/quickstart-pi.sh +``` + +Expected: clean. + +- [ ] **Step 3: Verify behavior on the dev machine** + +```bash +bash scripts/quickstart-pi.sh --help # prints usage, exits 0 (preflight not yet reached) +bash scripts/quickstart-pi.sh 2>&1 | head -3 +``` + +Expected: the second command fails with "Root required" if you're not root, or with "Unsupported architecture" if you're root on a non-aarch64 dev machine. Either of those is correct — both prove preflight runs. + +- [ ] **Step 4: Commit** + +```bash +git add scripts/quickstart-pi.sh +git commit -m "feat(quickstart): preflight checks for root, aarch64, debian, systemd" +``` + +--- + +## Task 4: Uninstall short-circuit + +**Files:** +- Modify: `scripts/quickstart-pi.sh` (replace `do_uninstall` stub) + +- [ ] **Step 1: Replace the `do_uninstall` stub** + +Find: +```bash +do_uninstall() { :; } +``` + +Replace with: +```bash +do_uninstall() { + log "Uninstalling sendspin-player..." + + if systemctl list-unit-files "${BINARY_NAME}.service" >/dev/null 2>&1; then + systemctl disable --now "${BINARY_NAME}.service" 2>/dev/null || true + fi + + rm -f "${INSTALL_PATH}" + rm -f "${UNIT_PATH}" + systemctl daemon-reload + + log "Uninstall complete." + log "Config preserved at ${CONFIG_DIR}/ and ${ENV_PATH}." + log "Remove manually for a full purge:" + log " sudo rm -rf ${CONFIG_DIR} ${ENV_PATH}" +} +``` + +- [ ] **Step 2: Run shellcheck** + +```bash +shellcheck scripts/quickstart-pi.sh +``` + +Expected: clean. + +- [ ] **Step 3: Commit** + +```bash +git add scripts/quickstart-pi.sh +git commit -m "feat(quickstart): --uninstall removes binary and unit, preserves config" +``` + +--- + +## Task 5: Apt runtime deps + +**Files:** +- Modify: `scripts/quickstart-pi.sh` (replace `install_apt_deps` stub) + +- [ ] **Step 1: Replace the `install_apt_deps` stub** + +Find: +```bash +install_apt_deps() { :; } +``` + +Replace with: +```bash +install_apt_deps() { + log "Installing runtime dependencies..." + DEBIAN_FRONTEND=noninteractive apt-get update -qq + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + libopus0 \ + libopusfile0 \ + libflac12 \ + libasound2 \ + ca-certificates \ + curl \ + tar +} +``` + +- [ ] **Step 2: Run shellcheck** + +```bash +shellcheck scripts/quickstart-pi.sh +``` + +Expected: clean. + +- [ ] **Step 3: Commit** + +```bash +git add scripts/quickstart-pi.sh +git commit -m "feat(quickstart): install runtime deps via apt-get" +``` + +--- + +## Task 6: Version resolution + +**Files:** +- Modify: `scripts/quickstart-pi.sh` (replace `resolve_version` stub) + +- [ ] **Step 1: Replace the `resolve_version` stub** + +Find: +```bash +resolve_version() { :; } +``` + +Replace with: +```bash +resolve_version() { + if [[ -n "${ARG_VERSION}" ]]; then + RESOLVED_TAG="${ARG_VERSION}" + RESOLVED_REF="${ARG_VERSION}" + log "Installing pinned version: ${RESOLVED_TAG}" + else + # Use GitHub's latest-release redirect: no API call, no JSON parsing. + # The redirect target tells us the resolved tag. + local redirect_url + redirect_url="$(curl -fsSLI -o /dev/null -w '%{url_effective}' \ + "${REPO_URL}/releases/latest")" \ + || die "Failed to resolve latest release tag from ${REPO_URL}/releases/latest" + RESOLVED_TAG="${redirect_url##*/}" + # The dist/ files at "main" are forward-compatible enough for the + # latest-tagged release; pin them to the same tag for consistency. + RESOLVED_REF="${RESOLVED_TAG}" + log "Installing latest release: ${RESOLVED_TAG}" + fi +} +``` + +- [ ] **Step 2: Run shellcheck** + +```bash +shellcheck scripts/quickstart-pi.sh +``` + +Expected: clean. + +- [ ] **Step 3: Commit** + +```bash +git add scripts/quickstart-pi.sh +git commit -m "feat(quickstart): resolve version via GitHub latest-release redirect" +``` + +--- + +## Task 7: Stop service if running + +**Files:** +- Modify: `scripts/quickstart-pi.sh` (replace `stop_service` stub) + +- [ ] **Step 1: Replace the `stop_service` stub** + +Find: +```bash +stop_service() { :; } +``` + +Replace with: +```bash +stop_service() { + if systemctl is-active --quiet "${BINARY_NAME}.service"; then + log "Stopping running ${BINARY_NAME} service..." + systemctl stop "${BINARY_NAME}.service" + fi +} +``` + +- [ ] **Step 2: Run shellcheck** + +```bash +shellcheck scripts/quickstart-pi.sh +``` + +Expected: clean. + +- [ ] **Step 3: Commit** + +```bash +git add scripts/quickstart-pi.sh +git commit -m "feat(quickstart): stop running service before binary swap" +``` + +--- + +## Task 8: Download, extract, and install the binary + +**Files:** +- Modify: `scripts/quickstart-pi.sh` (replace `install_binary` stub) + +- [ ] **Step 1: Replace the `install_binary` stub** + +Find: +```bash +install_binary() { :; } +``` + +Replace with: +```bash +install_binary() { + local tarball_url tarball_name tmpdir + tarball_name="${BINARY_NAME}-linux-arm64.tar.gz" + tarball_url="${REPO_URL}/releases/download/${RESOLVED_TAG}/${tarball_name}" + + tmpdir="$(mktemp -d)" + # shellcheck disable=SC2064 + trap "rm -rf '${tmpdir}'" EXIT + + log "Downloading ${tarball_url}..." + curl -fSL "${tarball_url}" -o "${tmpdir}/${tarball_name}" \ + || die "Failed to download release tarball from ${tarball_url}" + + log "Extracting..." + tar -xzf "${tmpdir}/${tarball_name}" -C "${tmpdir}" + + if [[ ! -f "${tmpdir}/${BINARY_NAME}" ]]; then + die "Tarball did not contain expected binary '${BINARY_NAME}'" + fi + + log "Installing ${INSTALL_PATH}..." + install -m 755 "${tmpdir}/${BINARY_NAME}" "${INSTALL_PATH}" +} +``` + +- [ ] **Step 2: Run shellcheck** + +```bash +shellcheck scripts/quickstart-pi.sh +``` + +Expected: clean (the `SC2064` disable is for the trap quoting, which is intentional — we want `${tmpdir}` expanded at trap-set time, not trap-fire time). + +- [ ] **Step 3: Commit** + +```bash +git add scripts/quickstart-pi.sh +git commit -m "feat(quickstart): download release tarball into tempdir, install binary" +``` + +--- + +## Task 9: Install the systemd unit + +**Files:** +- Modify: `scripts/quickstart-pi.sh` (replace `install_unit` stub) + +- [ ] **Step 1: Replace the `install_unit` stub** + +Find: +```bash +install_unit() { :; } +``` + +Replace with: +```bash +install_unit() { + local unit_url + unit_url="${RAW_URL_BASE}/${RESOLVED_REF}/dist/systemd/${BINARY_NAME}.service" + log "Installing systemd unit ${UNIT_PATH}..." + curl -fSL "${unit_url}" -o "${UNIT_PATH}" \ + || die "Failed to download unit file from ${unit_url}" + chmod 644 "${UNIT_PATH}" +} +``` + +- [ ] **Step 2: Run shellcheck** + +```bash +shellcheck scripts/quickstart-pi.sh +``` + +Expected: clean. + +- [ ] **Step 3: Commit** + +```bash +git add scripts/quickstart-pi.sh +git commit -m "feat(quickstart): install systemd unit from tagged ref" +``` + +--- + +## Task 10: Install the env file (with flag handling) + +**Files:** +- Modify: `scripts/quickstart-pi.sh` (replace `install_env` stub) + +- [ ] **Step 1: Replace the `install_env` stub** + +Find: +```bash +install_env() { :; } +``` + +Replace with: +```bash +# shell_quote: wrap a string in single quotes, escaping any embedded single +# quotes via the standard '\'' pattern. Safe for arbitrary user input. +shell_quote() { + local s="$1" + s="${s//\'/\'\\\'\'}" + printf "'%s'" "${s}" +} + +install_env() { + if [[ -n "${ARG_NAME}" || -n "${ARG_DEVICE}" ]]; then + log "Writing ${ENV_PATH} with --name/--device from flags..." + local opts="" + if [[ -n "${ARG_NAME}" ]]; then + opts+="--name $(shell_quote "${ARG_NAME}") " + fi + if [[ -n "${ARG_DEVICE}" ]]; then + opts+="--audio-device $(shell_quote "${ARG_DEVICE}") " + fi + # Trim trailing space. + opts="${opts% }" + cat >"${ENV_PATH}" <&2 + fi +} +trap on_exit EXIT +``` + +(Note: this `EXIT` trap coexists with the `install_binary` tempdir-cleanup trap. The tempdir trap is set inside that function and replaces this one only for the duration of `install_binary`. To avoid the conflict, change `install_binary`'s trap to be additive — see step 3.) + +- [ ] **Step 3: Make `install_binary`'s trap additive** + +In `install_binary`, replace: +```bash + # shellcheck disable=SC2064 + trap "rm -rf '${tmpdir}'" EXIT +``` + +With: +```bash + # shellcheck disable=SC2064 + trap "rm -rf '${tmpdir}'; on_exit" EXIT +``` + +This preserves both behaviors: tempdir cleanup AND the failure hint. + +- [ ] **Step 4: Run shellcheck** + +```bash +shellcheck scripts/quickstart-pi.sh +``` + +Expected: clean. + +- [ ] **Step 5: Run a syntax check** + +```bash +bash -n scripts/quickstart-pi.sh +``` + +Expected: clean exit 0. + +- [ ] **Step 6: Commit** + +```bash +git add scripts/quickstart-pi.sh +git commit -m "feat(quickstart): start/enable service, health-check, success message" +``` + +--- + +## Task 13: Add shellcheck to CI + +**Files:** +- Modify: `.github/workflows/ci.yml` + +- [ ] **Step 1: Add shellcheck install + step to the lint job** + +Open `.github/workflows/ci.yml`. Find the `lint` job's "Install dependencies" step: + +```yaml + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y libopus-dev libopusfile-dev libasound2-dev +``` + +Replace with: + +```yaml + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y libopus-dev libopusfile-dev libasound2-dev shellcheck +``` + +Then, immediately before the `- name: golangci-lint` step in the same job, add: + +```yaml + - name: Shellcheck + run: shellcheck scripts/*.sh install-deps.sh +``` + +(`install-deps.sh` is included so we get coverage on the existing script too — since shellcheck is now available in CI, there's no reason to leave it unchecked.) + +- [ ] **Step 2: Validate the YAML locally** + +```bash +python -c "import yaml; yaml.safe_load(open('.github/workflows/ci.yml'))" \ + && echo "yaml ok" +``` + +Expected: `yaml ok`. (If `python` isn't available, use `yq . .github/workflows/ci.yml` or `cat .github/workflows/ci.yml` and eyeball indentation.) + +- [ ] **Step 3: Run shellcheck locally on both scripts to confirm both pass** + +```bash +shellcheck scripts/quickstart-pi.sh install-deps.sh +``` + +Expected: clean exit 0. (If `install-deps.sh` produces warnings that aren't introduced by this PR, fix them in a separate commit before merging this task — don't bundle pre-existing fixes into the quickstart change.) + +- [ ] **Step 4: Commit** + +```bash +git add .github/workflows/ci.yml +git commit -m "chore(ci): run shellcheck on shell scripts in lint job" +``` + +--- + +## Task 14: README quickstart section + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Add a "Quickstart on Raspberry Pi" section** + +Open `README.md`. Find the existing install instructions (search for the "Install the library" or build-from-source section near the top). Immediately *before* the build-from-source instructions, add: + +```markdown +## 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` + +``` + +(Adjust the surrounding heading levels if the README uses `###` for that depth — match what's already there.) + +- [ ] **Step 2: Eyeball the rendered output** + +```bash +git diff README.md | head -40 +``` + +Confirm the markdown reads cleanly and the surrounding headings still flow. + +- [ ] **Step 3: Commit** + +```bash +git add README.md +git commit -m "docs(readme): add Pi quickstart section" +``` + +--- + +## Task 15: Smoke test in arm64 Docker + +**Files:** none (verification-only) + +This task verifies the script works end-to-end without needing a physical Pi. It uses Docker's QEMU-based arm64 emulation to simulate the target environment. The smoke test is intentionally not a CI check — it's a developer-machine acceptance step before merge. Systemd flows can't be exercised in a default Docker container, so we cover everything *up to* the systemd-reload step and stop there. + +- [ ] **Step 1: Ensure Docker buildx + qemu-static are available** + +```bash +docker run --rm --privileged multiarch/qemu-user-static --reset -p yes +``` + +(One-time setup on the dev machine. Idempotent.) + +- [ ] **Step 2: Run an interactive arm64 Bookworm container** + +```bash +docker run --rm -it --platform linux/arm64 \ + -v "$(pwd)/scripts/quickstart-pi.sh:/tmp/quickstart-pi.sh:ro" \ + arm64v8/debian:bookworm bash +``` + +- [ ] **Step 3: Inside the container, install systemctl shim and run the script** + +In the container: +```bash +apt-get update -qq && apt-get install -y -qq curl ca-certificates +# Provide a no-op systemctl so the script's preflight passes; we'll skip +# the actual systemd interaction in the next step. +cat >/usr/local/bin/systemctl <<'EOF' +#!/bin/bash +echo "systemctl-shim: $*" >&2 +case "$1" in + is-active) exit 1 ;; # pretend service is not active + list-unit-files) exit 0 ;; + *) exit 0 ;; +esac +EOF +chmod +x /usr/local/bin/systemctl +bash /tmp/quickstart-pi.sh --version v1.6.2 --name "Smoke Test" +``` + +Expected: the script downloads the v1.6.2 tarball, installs the binary at `/usr/local/bin/sendspin-player`, writes the unit file, writes `/etc/default/sendspin-player` with `SENDSPIN_PLAYER_OPTS="--name 'Smoke Test'"`, and writes `/etc/sendspin/player.yaml`. Final "service failed to come up" warning is *expected* — the systemctl shim returns non-zero on `is-active`, which surfaces the health-check failure path. That confirms the health check works. + +- [ ] **Step 4: Inside the container, sanity-check the installed artifacts** + +```bash +ls -la /usr/local/bin/sendspin-player /etc/systemd/system/sendspin-player.service \ + /etc/default/sendspin-player /etc/sendspin/player.yaml +cat /etc/default/sendspin-player +/usr/local/bin/sendspin-player --version +``` + +Expected: all four files present; env file shows `--name 'Smoke Test'`; binary prints v1.6.2. + +- [ ] **Step 5: Run idempotency check** + +In the same container: +```bash +bash /tmp/quickstart-pi.sh --version v1.6.2 +``` + +Expected: clean run, "Preserving existing /etc/sendspin/player.yaml" message, env file *not* rewritten (still shows the prior `Smoke Test` name). + +- [ ] **Step 6: Run uninstall** + +```bash +bash /tmp/quickstart-pi.sh --uninstall +ls /usr/local/bin/sendspin-player /etc/systemd/system/sendspin-player.service 2>&1 +ls /etc/sendspin/player.yaml /etc/default/sendspin-player +``` + +Expected: binary and unit gone; config dir and env file preserved. + +- [ ] **Step 7: Final pass — real Pi** + +On a fresh 64-bit Pi OS Lite install: + +```bash +curl -fsSL https://raw.githubusercontent.com/Sendspin/sendspin-go/main/scripts/quickstart-pi.sh | sudo bash +``` + +Verify: the player appears in mDNS / Music Assistant within ~10 seconds, audio plays cleanly, `journalctl -u sendspin-player -f` shows the expected "connected to server" / chunk-decode logs. + +- [ ] **Step 8: No commit needed for this task** — it's verification-only. If anything failed in steps 3–7, file fixes as additional commits and re-run from step 3. diff --git a/third_party/sendspin-go/docs/superpowers/specs/2026-04-20-server-config-yaml-design.md b/third_party/sendspin-go/docs/superpowers/specs/2026-04-20-server-config-yaml-design.md new file mode 100644 index 0000000..fa5e767 --- /dev/null +++ b/third_party/sendspin-go/docs/superpowers/specs/2026-04-20-server-config-yaml-design.md @@ -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. `/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 +# 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_ 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` | `-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_` 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. diff --git a/third_party/sendspin-go/docs/superpowers/specs/2026-05-01-quickstart-pi-design.md b/third_party/sendspin-go/docs/superpowers/specs/2026-05-01-quickstart-pi-design.md new file mode 100644 index 0000000..9415d67 --- /dev/null +++ b/third_party/sendspin-go/docs/superpowers/specs/2026-05-01-quickstart-pi-design.md @@ -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 ""` 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//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//` 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 `, `--device `, `--version `, `--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//sendspin-player-linux-arm64.tar.gz`. Same logic produces the `dist/...` raw URL ref: `main` for default, `` for pinned. +6. **Stop service if running.** `systemctl is-active --quiet sendspin-player && systemctl stop sendspin-player`. +7. **Download + extract.** `curl -fSL -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 /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 --audio-device "` 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 /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. diff --git a/third_party/sendspin-go/examples/README.md b/third_party/sendspin-go/examples/README.md new file mode 100644 index 0000000..b14c835 --- /dev/null +++ b/third_party/sendspin-go/examples/README.md @@ -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. diff --git a/third_party/sendspin-go/examples/basic-player/README.md b/third_party/sendspin-go/examples/basic-player/README.md new file mode 100644 index 0000000..efdb522 --- /dev/null +++ b/third_party/sendspin-go/examples/basic-player/README.md @@ -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 diff --git a/third_party/sendspin-go/examples/basic-player/main.go b/third_party/sendspin-go/examples/basic-player/main.go new file mode 100644 index 0000000..f6f9860 --- /dev/null +++ b/third_party/sendspin-go/examples/basic-player/main.go @@ -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...") +} diff --git a/third_party/sendspin-go/examples/basic-server/README.md b/third_party/sendspin-go/examples/basic-server/README.md new file mode 100644 index 0000000..2ea849b --- /dev/null +++ b/third_party/sendspin-go/examples/basic-server/README.md @@ -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 diff --git a/third_party/sendspin-go/examples/basic-server/main.go b/third_party/sendspin-go/examples/basic-server/main.go new file mode 100644 index 0000000..cd2592f --- /dev/null +++ b/third_party/sendspin-go/examples/basic-server/main.go @@ -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") +} diff --git a/third_party/sendspin-go/examples/custom-source/README.md b/third_party/sendspin-go/examples/custom-source/README.md new file mode 100644 index 0000000..f946954 --- /dev/null +++ b/third_party/sendspin-go/examples/custom-source/README.md @@ -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 diff --git a/third_party/sendspin-go/examples/custom-source/main.go b/third_party/sendspin-go/examples/custom-source/main.go new file mode 100644 index 0000000..aa2a4de --- /dev/null +++ b/third_party/sendspin-go/examples/custom-source/main.go @@ -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") +} diff --git a/third_party/sendspin-go/go.mod b/third_party/sendspin-go/go.mod new file mode 100644 index 0000000..73bb030 --- /dev/null +++ b/third_party/sendspin-go/go.mod @@ -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 +) diff --git a/third_party/sendspin-go/go.sum b/third_party/sendspin-go/go.sum new file mode 100644 index 0000000..5cf1f04 --- /dev/null +++ b/third_party/sendspin-go/go.sum @@ -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= diff --git a/third_party/sendspin-go/install-deps.sh b/third_party/sendspin-go/install-deps.sh new file mode 100644 index 0000000..683e901 --- /dev/null +++ b/third_party/sendspin-go/install-deps.sh @@ -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 ./..." diff --git a/third_party/sendspin-go/internal/discovery/mdns.go b/third_party/sendspin-go/internal/discovery/mdns.go new file mode 100644 index 0000000..9b2c7c7 --- /dev/null +++ b/third_party/sendspin-go/internal/discovery/mdns.go @@ -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 +} diff --git a/third_party/sendspin-go/internal/discovery/mdns_test.go b/third_party/sendspin-go/internal/discovery/mdns_test.go new file mode 100644 index 0000000..b8f78ab --- /dev/null +++ b/third_party/sendspin-go/internal/discovery/mdns_test.go @@ -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: + } +} diff --git a/third_party/sendspin-go/internal/discovery/txt.go b/third_party/sendspin-go/internal/discovery/txt.go new file mode 100644 index 0000000..9509891 --- /dev/null +++ b/third_party/sendspin-go/internal/discovery/txt.go @@ -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 +} diff --git a/third_party/sendspin-go/internal/discovery/txt_test.go b/third_party/sendspin-go/internal/discovery/txt_test.go new file mode 100644 index 0000000..acc9d57 --- /dev/null +++ b/third_party/sendspin-go/internal/discovery/txt_test.go @@ -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) + } + }) + } +} diff --git a/third_party/sendspin-go/internal/server/audio_engine.go b/third_party/sendspin-go/internal/server/audio_engine.go new file mode 100644 index 0000000..4282035 --- /dev/null +++ b/third_party/sendspin-go/internal/server/audio_engine.go @@ -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 +} diff --git a/third_party/sendspin-go/internal/server/audio_engine_test.go b/third_party/sendspin-go/internal/server/audio_engine_test.go new file mode 100644 index 0000000..445c5ca --- /dev/null +++ b/third_party/sendspin-go/internal/server/audio_engine_test.go @@ -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") + } +} diff --git a/third_party/sendspin-go/internal/server/audio_source.go b/third_party/sendspin-go/internal/server/audio_source.go new file mode 100644 index 0000000..af08283 --- /dev/null +++ b/third_party/sendspin-go/internal/server/audio_source.go @@ -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() +} diff --git a/third_party/sendspin-go/internal/server/flac_encoder.go b/third_party/sendspin-go/internal/server/flac_encoder.go new file mode 100644 index 0000000..18ea2a2 --- /dev/null +++ b/third_party/sendspin-go/internal/server/flac_encoder.go @@ -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() +} diff --git a/third_party/sendspin-go/internal/server/flac_encoder_test.go b/third_party/sendspin-go/internal/server/flac_encoder_test.go new file mode 100644 index 0000000..99508fe --- /dev/null +++ b/third_party/sendspin-go/internal/server/flac_encoder_test.go @@ -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) + } +} diff --git a/third_party/sendspin-go/internal/server/opus_encoder.go b/third_party/sendspin-go/internal/server/opus_encoder.go new file mode 100644 index 0000000..a0f2786 --- /dev/null +++ b/third_party/sendspin-go/internal/server/opus_encoder.go @@ -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 +} diff --git a/third_party/sendspin-go/internal/server/opus_encoder_test.go b/third_party/sendspin-go/internal/server/opus_encoder_test.go new file mode 100644 index 0000000..e17bf8b --- /dev/null +++ b/third_party/sendspin-go/internal/server/opus_encoder_test.go @@ -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") + } +} diff --git a/third_party/sendspin-go/internal/server/resampler.go b/third_party/sendspin-go/internal/server/resampler.go new file mode 100644 index 0000000..efa0ff7 --- /dev/null +++ b/third_party/sendspin-go/internal/server/resampler.go @@ -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 +} diff --git a/third_party/sendspin-go/internal/server/resampler_test.go b/third_party/sendspin-go/internal/server/resampler_test.go new file mode 100644 index 0000000..77976ea --- /dev/null +++ b/third_party/sendspin-go/internal/server/resampler_test.go @@ -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 +} diff --git a/third_party/sendspin-go/internal/server/server.go b/third_party/sendspin-go/internal/server/server.go new file mode 100644 index 0000000..f142e7f --- /dev/null +++ b/third_party/sendspin-go/internal/server/server.go @@ -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 +} diff --git a/third_party/sendspin-go/internal/server/test_tone_source.go b/third_party/sendspin-go/internal/server/test_tone_source.go new file mode 100644 index 0000000..8752317 --- /dev/null +++ b/third_party/sendspin-go/internal/server/test_tone_source.go @@ -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 } diff --git a/third_party/sendspin-go/internal/server/tui.go b/third_party/sendspin-go/internal/server/tui.go new file mode 100644 index 0000000..61b048b --- /dev/null +++ b/third_party/sendspin-go/internal/server/tui.go @@ -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 +} diff --git a/third_party/sendspin-go/internal/server/tui_update.go b/third_party/sendspin-go/internal/server/tui_update.go new file mode 100644 index 0000000..22c6eb2 --- /dev/null +++ b/third_party/sendspin-go/internal/server/tui_update.go @@ -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, + }) +} diff --git a/third_party/sendspin-go/internal/ui/device_picker.go b/third_party/sendspin-go/internal/ui/device_picker.go new file mode 100644 index 0000000..122b4fc --- /dev/null +++ b/third_party/sendspin-go/internal/ui/device_picker.go @@ -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) +} diff --git a/third_party/sendspin-go/internal/ui/device_picker_test.go b/third_party/sendspin-go/internal/ui/device_picker_test.go new file mode 100644 index 0000000..a59def3 --- /dev/null +++ b/third_party/sendspin-go/internal/ui/device_picker_test.go @@ -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) + } +} diff --git a/third_party/sendspin-go/internal/ui/hotkey.go b/third_party/sendspin-go/internal/ui/hotkey.go new file mode 100644 index 0000000..f764dfd --- /dev/null +++ b/third_party/sendspin-go/internal/ui/hotkey.go @@ -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 "[] 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 +} diff --git a/third_party/sendspin-go/internal/ui/hotkey_test.go b/third_party/sendspin-go/internal/ui/hotkey_test.go new file mode 100644 index 0000000..4c930d0 --- /dev/null +++ b/third_party/sendspin-go/internal/ui/hotkey_test.go @@ -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) + } +} diff --git a/third_party/sendspin-go/internal/ui/model.go b/third_party/sendspin-go/internal/ui/model.go new file mode 100644 index 0000000..173866d --- /dev/null +++ b/third_party/sendspin-go/internal/ui/model.go @@ -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: " 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} + }) +} diff --git a/third_party/sendspin-go/internal/ui/model_test.go b/third_party/sendspin-go/internal/ui/model_test.go new file mode 100644 index 0000000..02f4e36 --- /dev/null +++ b/third_party/sendspin-go/internal/ui/model_test.go @@ -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. diff --git a/third_party/sendspin-go/internal/ui/tui.go b/third_party/sendspin-go/internal/ui/tui.go new file mode 100644 index 0000000..f46cb99 --- /dev/null +++ b/third_party/sendspin-go/internal/ui/tui.go @@ -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 +} diff --git a/third_party/sendspin-go/internal/version/version.go b/third_party/sendspin-go/internal/version/version.go new file mode 100644 index 0000000..424ebfb --- /dev/null +++ b/third_party/sendspin-go/internal/version/version.go @@ -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" +) diff --git a/third_party/sendspin-go/internal/version/version_test.go b/third_party/sendspin-go/internal/version/version_test.go new file mode 100644 index 0000000..d851d9e --- /dev/null +++ b/third_party/sendspin-go/internal/version/version_test.go @@ -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) + } + } +} diff --git a/third_party/sendspin-go/main.go b/third_party/sendspin-go/main.go new file mode 100644 index 0000000..e2c1cdd --- /dev/null +++ b/third_party/sendspin-go/main.go @@ -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 \"\" or set audio_device: in player.yaml.") + fmt.Fprintln(w, "On Linux, miniaudio names look like \", \"; 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(), + }) + } +} diff --git a/third_party/sendspin-go/pkg/audio/decode/decoder.go b/third_party/sendspin-go/pkg/audio/decode/decoder.go new file mode 100644 index 0000000..237a7d1 --- /dev/null +++ b/third_party/sendspin-go/pkg/audio/decode/decoder.go @@ -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 +} diff --git a/third_party/sendspin-go/pkg/audio/decode/doc.go b/third_party/sendspin-go/pkg/audio/decode/doc.go new file mode 100644 index 0000000..b3b7cdf --- /dev/null +++ b/third_party/sendspin-go/pkg/audio/decode/doc.go @@ -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 diff --git a/third_party/sendspin-go/pkg/audio/decode/flac.go b/third_party/sendspin-go/pkg/audio/decode/flac.go new file mode 100644 index 0000000..da3ee37 --- /dev/null +++ b/third_party/sendspin-go/pkg/audio/decode/flac.go @@ -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 +} diff --git a/third_party/sendspin-go/pkg/audio/decode/flac_integration_test.go b/third_party/sendspin-go/pkg/audio/decode/flac_integration_test.go new file mode 100644 index 0000000..b561c8a --- /dev/null +++ b/third_party/sendspin-go/pkg/audio/decode/flac_integration_test.go @@ -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)) +} diff --git a/third_party/sendspin-go/pkg/audio/decode/flac_test.go b/third_party/sendspin-go/pkg/audio/decode/flac_test.go new file mode 100644 index 0000000..1169831 --- /dev/null +++ b/third_party/sendspin-go/pkg/audio/decode/flac_test.go @@ -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") + } +} diff --git a/third_party/sendspin-go/pkg/audio/decode/opus.go b/third_party/sendspin-go/pkg/audio/decode/opus.go new file mode 100644 index 0000000..6e903ae --- /dev/null +++ b/third_party/sendspin-go/pkg/audio/decode/opus.go @@ -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 +} diff --git a/third_party/sendspin-go/pkg/audio/decode/opus_test.go b/third_party/sendspin-go/pkg/audio/decode/opus_test.go new file mode 100644 index 0000000..9eab098 --- /dev/null +++ b/third_party/sendspin-go/pkg/audio/decode/opus_test.go @@ -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) + } +} diff --git a/third_party/sendspin-go/pkg/audio/decode/pcm.go b/third_party/sendspin-go/pkg/audio/decode/pcm.go new file mode 100644 index 0000000..90e1c43 --- /dev/null +++ b/third_party/sendspin-go/pkg/audio/decode/pcm.go @@ -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 +} diff --git a/third_party/sendspin-go/pkg/audio/decode/pcm_test.go b/third_party/sendspin-go/pkg/audio/decode/pcm_test.go new file mode 100644 index 0000000..8a94180 --- /dev/null +++ b/third_party/sendspin-go/pkg/audio/decode/pcm_test.go @@ -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)) + } +} diff --git a/third_party/sendspin-go/pkg/audio/doc.go b/third_party/sendspin-go/pkg/audio/doc.go new file mode 100644 index 0000000..da9d2a0 --- /dev/null +++ b/third_party/sendspin-go/pkg/audio/doc.go @@ -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 diff --git a/third_party/sendspin-go/pkg/audio/encode/doc.go b/third_party/sendspin-go/pkg/audio/encode/doc.go new file mode 100644 index 0000000..e43ad6f --- /dev/null +++ b/third_party/sendspin-go/pkg/audio/encode/doc.go @@ -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 diff --git a/third_party/sendspin-go/pkg/audio/encode/encoder.go b/third_party/sendspin-go/pkg/audio/encode/encoder.go new file mode 100644 index 0000000..ed9585f --- /dev/null +++ b/third_party/sendspin-go/pkg/audio/encode/encoder.go @@ -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 +} diff --git a/third_party/sendspin-go/pkg/audio/encode/opus.go b/third_party/sendspin-go/pkg/audio/encode/opus.go new file mode 100644 index 0000000..045d798 --- /dev/null +++ b/third_party/sendspin-go/pkg/audio/encode/opus.go @@ -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 +} diff --git a/third_party/sendspin-go/pkg/audio/encode/opus_test.go b/third_party/sendspin-go/pkg/audio/encode/opus_test.go new file mode 100644 index 0000000..76ed0ff --- /dev/null +++ b/third_party/sendspin-go/pkg/audio/encode/opus_test.go @@ -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) + } +} diff --git a/third_party/sendspin-go/pkg/audio/encode/pcm.go b/third_party/sendspin-go/pkg/audio/encode/pcm.go new file mode 100644 index 0000000..515c2a4 --- /dev/null +++ b/third_party/sendspin-go/pkg/audio/encode/pcm.go @@ -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 +} diff --git a/third_party/sendspin-go/pkg/audio/encode/pcm_test.go b/third_party/sendspin-go/pkg/audio/encode/pcm_test.go new file mode 100644 index 0000000..3b817f1 --- /dev/null +++ b/third_party/sendspin-go/pkg/audio/encode/pcm_test.go @@ -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 +} diff --git a/third_party/sendspin-go/pkg/audio/output/doc.go b/third_party/sendspin-go/pkg/audio/output/doc.go new file mode 100644 index 0000000..da205ad --- /dev/null +++ b/third_party/sendspin-go/pkg/audio/output/doc.go @@ -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 diff --git a/third_party/sendspin-go/pkg/audio/output/malgo.go b/third_party/sendspin-go/pkg/audio/output/malgo.go new file mode 100644 index 0000000..1a5dd5b --- /dev/null +++ b/third_party/sendspin-go/pkg/audio/output/malgo.go @@ -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 +// ", " 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) + } +} diff --git a/third_party/sendspin-go/pkg/audio/output/malgo_test.go b/third_party/sendspin-go/pkg/audio/output/malgo_test.go new file mode 100644 index 0000000..b283990 --- /dev/null +++ b/third_party/sendspin-go/pkg/audio/output/malgo_test.go @@ -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 ", " — 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) + } +} diff --git a/third_party/sendspin-go/pkg/audio/output/output.go b/third_party/sendspin-go/pkg/audio/output/output.go new file mode 100644 index 0000000..98c24b0 --- /dev/null +++ b/third_party/sendspin-go/pkg/audio/output/output.go @@ -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) +} diff --git a/third_party/sendspin-go/pkg/audio/output/output_test.go b/third_party/sendspin-go/pkg/audio/output/output_test.go new file mode 100644 index 0000000..b57d769 --- /dev/null +++ b/third_party/sendspin-go/pkg/audio/output/output_test.go @@ -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) +} diff --git a/third_party/sendspin-go/pkg/audio/output/query.go b/third_party/sendspin-go/pkg/audio/output/query.go new file mode 100644 index 0000000..82079bf --- /dev/null +++ b/third_party/sendspin-go/pkg/audio/output/query.go @@ -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 + } +} diff --git a/third_party/sendspin-go/pkg/audio/output/query_test.go b/third_party/sendspin-go/pkg/audio/output/query_test.go new file mode 100644 index 0000000..6be0f15 --- /dev/null +++ b/third_party/sendspin-go/pkg/audio/output/query_test.go @@ -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) + } +} diff --git a/third_party/sendspin-go/pkg/audio/output/volume.go b/third_party/sendspin-go/pkg/audio/output/volume.go new file mode 100644 index 0000000..c6a1846 --- /dev/null +++ b/third_party/sendspin-go/pkg/audio/output/volume.go @@ -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 +} diff --git a/third_party/sendspin-go/pkg/audio/output/volume_test.go b/third_party/sendspin-go/pkg/audio/output/volume_test.go new file mode 100644 index 0000000..3e3c949 --- /dev/null +++ b/third_party/sendspin-go/pkg/audio/output/volume_test.go @@ -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]) + } +} diff --git a/third_party/sendspin-go/pkg/audio/resample.go b/third_party/sendspin-go/pkg/audio/resample.go new file mode 100644 index 0000000..416a829 --- /dev/null +++ b/third_party/sendspin-go/pkg/audio/resample.go @@ -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 +} diff --git a/third_party/sendspin-go/pkg/audio/types.go b/third_party/sendspin-go/pkg/audio/types.go new file mode 100644 index 0000000..771b6ee --- /dev/null +++ b/third_party/sendspin-go/pkg/audio/types.go @@ -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 +} diff --git a/third_party/sendspin-go/pkg/audio/types_test.go b/third_party/sendspin-go/pkg/audio/types_test.go new file mode 100644 index 0000000..b9d0ad5 --- /dev/null +++ b/third_party/sendspin-go/pkg/audio/types_test.go @@ -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) + } + } +} diff --git a/third_party/sendspin-go/pkg/discovery/doc.go b/third_party/sendspin-go/pkg/discovery/doc.go new file mode 100644 index 0000000..69f5b7f --- /dev/null +++ b/third_party/sendspin-go/pkg/discovery/doc.go @@ -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 diff --git a/third_party/sendspin-go/pkg/discovery/mdns.go b/third_party/sendspin-go/pkg/discovery/mdns.go new file mode 100644 index 0000000..88e70a9 --- /dev/null +++ b/third_party/sendspin-go/pkg/discovery/mdns.go @@ -0,0 +1,169 @@ +// 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" + + "github.com/hashicorp/mdns" +) + +// silentLogger discards hashicorp/mdns internal logs +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 +} + +type ServerInfo struct { + Name string + Host string + Port int +} + +func NewManager(config Config) *Manager { + ctx, cancel := context.WithCancel(context.Background()) + + return &Manager{ + config: config, + ctx: ctx, + cancel: cancel, + servers: make(chan *ServerInfo, 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 { + 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, + Entries: entries, + Logger: silentLogger, + } + + mdns.Query(params) + close(entries) + } +} + +func (m *Manager) Servers() <-chan *ServerInfo { + return m.servers +} + +func (m *Manager) Stop() { + m.cancel() +} + +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 +} diff --git a/third_party/sendspin-go/pkg/discovery/mdns_test.go b/third_party/sendspin-go/pkg/discovery/mdns_test.go new file mode 100644 index 0000000..b6ef1ed --- /dev/null +++ b/third_party/sendspin-go/pkg/discovery/mdns_test.go @@ -0,0 +1,196 @@ +// ABOUTME: Tests for mDNS service discovery +// ABOUTME: Validates Manager creation, configuration, and lifecycle +package discovery + +import ( + "testing" + "time" +) + +func TestNewManager(t *testing.T) { + config := Config{ + ServiceName: "test-service", + Port: 8080, + ServerMode: false, + } + + manager := NewManager(config) + + if manager == nil { + t.Fatal("NewManager returned nil") + } + + if manager.config.ServiceName != "test-service" { + t.Errorf("Expected ServiceName 'test-service', got '%s'", manager.config.ServiceName) + } + + if manager.config.Port != 8080 { + t.Errorf("Expected Port 8080, got %d", manager.config.Port) + } + + if manager.config.ServerMode != false { + t.Errorf("Expected ServerMode false, got %v", manager.config.ServerMode) + } + + if manager.servers == nil { + t.Error("servers channel should not be nil") + } + + if manager.ctx == nil { + t.Error("ctx should not be nil") + } + + if manager.cancel == nil { + t.Error("cancel should not be nil") + } + + manager.Stop() +} + +func TestManagerServerMode(t *testing.T) { + config := Config{ + ServiceName: "test-server", + Port: 9090, + ServerMode: true, + } + + manager := NewManager(config) + defer manager.Stop() + + if !manager.config.ServerMode { + t.Error("Expected ServerMode to be true") + } +} + +func TestManagerServersChannel(t *testing.T) { + config := Config{ + ServiceName: "test", + Port: 8080, + ServerMode: false, + } + + manager := NewManager(config) + defer manager.Stop() + + serversChan := manager.Servers() + + if serversChan == nil { + t.Fatal("Servers() returned nil channel") + } + + if serversChan != manager.servers { + t.Error("Servers() should return the manager's servers channel") + } +} + +func TestManagerStop(t *testing.T) { + config := Config{ + ServiceName: "test", + Port: 8080, + ServerMode: false, + } + + manager := NewManager(config) + + manager.Stop() + + select { + case <-manager.ctx.Done(): + // Expected - context should be cancelled + case <-time.After(100 * time.Millisecond): + t.Error("Context should be cancelled after Stop()") + } +} + +func TestGetLocalIPs(t *testing.T) { + ips, err := getLocalIPs() + + if err != nil { + t.Fatalf("getLocalIPs failed: %v", err) + } + + // We should have at least one non-loopback IPv4 address on most systems + // This test may be environment-dependent, so we just verify it doesn't crash + // and returns a non-nil slice + if ips == nil { + t.Error("getLocalIPs returned nil slice") + } + + for _, ip := range ips { + if ip.To4() == nil { + t.Errorf("getLocalIPs returned non-IPv4 address: %v", ip) + } + if ip.IsLoopback() { + t.Errorf("getLocalIPs returned loopback address: %v", ip) + } + } +} + +func TestServerInfo(t *testing.T) { + info := &ServerInfo{ + Name: "test-server", + Host: "192.168.1.100", + Port: 8080, + } + + if info.Name != "test-server" { + t.Errorf("Expected Name 'test-server', got '%s'", info.Name) + } + + if info.Host != "192.168.1.100" { + t.Errorf("Expected Host '192.168.1.100', got '%s'", info.Host) + } + + if info.Port != 8080 { + t.Errorf("Expected Port 8080, got %d", info.Port) + } +} + +func TestConfigValidation(t *testing.T) { + tests := []struct { + name string + config Config + }{ + { + name: "client mode", + config: Config{ + ServiceName: "client", + Port: 8080, + ServerMode: false, + }, + }, + { + name: "server mode", + config: Config{ + ServiceName: "server", + Port: 9090, + ServerMode: true, + }, + }, + { + name: "different port", + config: Config{ + ServiceName: "test", + Port: 12345, + ServerMode: false, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + manager := NewManager(tt.config) + defer manager.Stop() + + if manager.config.ServiceName != tt.config.ServiceName { + t.Errorf("ServiceName: expected %s, got %s", tt.config.ServiceName, manager.config.ServiceName) + } + if manager.config.Port != tt.config.Port { + t.Errorf("Port: expected %d, got %d", tt.config.Port, manager.config.Port) + } + if manager.config.ServerMode != tt.config.ServerMode { + t.Errorf("ServerMode: expected %v, got %v", tt.config.ServerMode, manager.config.ServerMode) + } + }) + } +} diff --git a/third_party/sendspin-go/pkg/protocol/client.go b/third_party/sendspin-go/pkg/protocol/client.go new file mode 100644 index 0000000..b152fa6 --- /dev/null +++ b/third_party/sendspin-go/pkg/protocol/client.go @@ -0,0 +1,642 @@ +// ABOUTME: WebSocket client for Sendspin Protocol communication +// ABOUTME: Handles connection, handshake, and message routing +package protocol + +import ( + "context" + "encoding/binary" + "encoding/json" + "fmt" + "log" + "net/url" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +const ( + // BinaryMessageHeaderSize is the size of binary message header (type byte + timestamp) + BinaryMessageHeaderSize = 1 + 8 // 9 bytes: 1 byte type + 8 byte timestamp + + // AudioChunkMessageType is the binary message type ID for audio chunks. + // Per spec: Player role binary messages use IDs 4-7 (bits 000001xx), slot 0 is audio. + AudioChunkMessageType = 4 + + // Artwork binary message type IDs per spec: Artwork role uses 8-11, one per channel. + // ArtworkChannel0MessageType is channel 0; channels 1-3 use 9, 10, 11 respectively. + ArtworkChannel0MessageType = 8 + ArtworkChannel1MessageType = 9 + ArtworkChannel2MessageType = 10 + ArtworkChannel3MessageType = 11 + + // ArtworkChannelCount is the maximum number of artwork channels the spec allocates binary IDs for. + ArtworkChannelCount = 4 +) + +// Heartbeat parameters. Vars (not consts) so tests can override with shorter +// values; production code never mutates them. +// +// pingPeriod: how often we send a control Ping to the server. +// pongWait: read deadline; reset on every Pong arrival. +// writeWait: bound on each WriteControl call so a slow socket doesn't +// block the ping goroutine forever. +// +// Invariant: pingPeriod < pongWait. Otherwise the deadline can expire +// before our next ping has a chance to elicit a pong. +var ( + pingPeriod = 30 * time.Second + pongWait = 60 * time.Second + writeWait = 10 * time.Second +) + +type Config struct { + ServerAddr string + ClientID string + Name string + Version int + DeviceInfo DeviceInfo + PlayerV1Support PlayerV1Support + ArtworkV1Support *ArtworkV1Support + VisualizerV1Support *VisualizerV1Support + + // SupportedRoles overrides the auto-built role list in the client/hello + // message. When nil or empty, handshake() builds the list from the V1 + // support fields above (player@v1 + metadata@v1 always, plus artwork@v1 + // and visualizer@v1 when their support structs are set). Set this to + // advertise a specific subset — e.g. []string{"metadata@v1"} for a + // metadata-only client, or []string{"controller@v1"} for controller + // scenarios where advertising player@v1 would incorrectly activate + // the audio stream. + SupportedRoles []string +} + +type Client struct { + config Config + conn *websocket.Conn + mu sync.RWMutex + + AudioChunks chan AudioChunk + ArtworkChunks chan ArtworkChunk + ControlMsgs chan PlayerCommand + TimeSyncResp chan ServerTime + StreamStart chan StreamStart + StreamClear chan StreamClear + StreamEnd chan StreamEnd + ServerState chan ServerStateMessage + GroupUpdate chan GroupUpdate + + // serverHello holds the parsed server/hello message received during + // handshake. Nil until Start()/Connect() completes successfully. + serverHello *ServerHello + + // rawServerHello holds the raw JSON envelope bytes of the server/hello + // message, useful for conformance testing and protocol debugging where + // the exact wire representation matters. + rawServerHello []byte + + connected bool + ctx context.Context + cancel context.CancelFunc +} + +type AudioChunk struct { + Timestamp int64 // Microseconds, server clock + Data []byte +} + +// ArtworkChunk is an incoming binary artwork frame routed from the artwork@v1 role. +type ArtworkChunk struct { + Channel int // 0-3; derived from the binary message type minus ArtworkChannel0MessageType + Timestamp int64 // Microseconds, server clock + Data []byte +} + +func NewClient(config Config) *Client { + return newClient(config) +} + +// NewClientFromConn wraps an already-established websocket connection. Use +// this for server-initiated scenarios: the caller has accepted an incoming +// connection on a listening socket and now needs the library to run the +// client-side protocol (handshake, message loop, channel routing) over it. +// +// Unlike NewClient+Connect, the returned client is NOT yet running — call +// Start to perform the handshake and launch the read loop. +// +// The caller transfers ownership of conn to the client; Close will close it. +func NewClientFromConn(config Config, conn *websocket.Conn) *Client { + c := newClient(config) + c.conn = conn + c.connected = true + return c +} + +func newClient(config Config) *Client { + ctx, cancel := context.WithCancel(context.Background()) + + return &Client{ + config: config, + AudioChunks: make(chan AudioChunk, 100), + ArtworkChunks: make(chan ArtworkChunk, 10), + ControlMsgs: make(chan PlayerCommand, 10), + TimeSyncResp: make(chan ServerTime, 10), + StreamStart: make(chan StreamStart, 1), + StreamClear: make(chan StreamClear, 10), + StreamEnd: make(chan StreamEnd, 1), + ServerState: make(chan ServerStateMessage, 10), + GroupUpdate: make(chan GroupUpdate, 10), + ctx: ctx, + cancel: cancel, + } +} + +// Connect dials the configured server, runs the handshake, and starts the +// message read loop. Use NewClientFromConn + Start when you already have +// an accepted connection (server-initiated scenarios). +func (c *Client) Connect() error { + u := url.URL{Scheme: "ws", Host: c.config.ServerAddr, Path: "/sendspin"} + log.Printf("Connecting to %s", u.String()) + + conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil) + if err != nil { + return fmt.Errorf("dial failed: %w", err) + } + + c.mu.Lock() + c.conn = conn + c.connected = true + c.mu.Unlock() + + return c.Start() +} + +// Start runs the client/hello handshake and launches the background read +// loop. The connection must already be set via NewClientFromConn or Connect. +// Calling Start more than once on the same client is undefined. +func (c *Client) Start() error { + c.mu.RLock() + conn := c.conn + c.mu.RUnlock() + if conn == nil { + return fmt.Errorf("no connection: use NewClientFromConn or Connect before Start") + } + + conn.SetReadLimit(1 << 20) // 1MB + + if err := c.handshake(); err != nil { + c.Close() + return fmt.Errorf("handshake failed: %w", err) + } + + // Snapshot the heartbeat vars on Start's goroutine so the values we + // pass to pingLoop and the PongHandler are read here, not from the + // goroutines we're about to spawn. Tests mutate these package-level + // vars between test cases; the snapshot establishes a happens-before + // from the mutation site to the goroutine read, which the race + // detector requires. + pp, pw, ww := pingPeriod, pongWait, writeWait + + // Install PongHandler that resets the read deadline. The handler runs + // synchronously from ReadMessage's goroutine, so SetReadDeadline is + // safe to call from here. + conn.SetPongHandler(func(string) error { + return conn.SetReadDeadline(time.Now().Add(pw)) + }) + + // Initial deadline. If the server never pongs, ReadMessage in + // readMessages will hit this deadline and exit, triggering Close(). + if err := conn.SetReadDeadline(time.Now().Add(pw)); err != nil { + c.Close() + return fmt.Errorf("set read deadline: %w", err) + } + + go c.readMessages() + go c.pingLoop(pp, ww) + + return nil +} + +// pingLoop sends a periodic WebSocket control ping. The server's pong +// reply arrives via the PongHandler installed in Start, which resets +// the read deadline. Exits on context cancel or write failure; on +// write failure we just return — readMessages's defer is responsible +// for the Close, so calling it from here would race that path. +// +// The period and write deadline are passed as args (rather than read +// from the package vars) so the read happens-before the goroutine +// launch; tests that mutate the package vars between cases stay clean +// under -race. +func (c *Client) pingLoop(period, writeDeadline time.Duration) { + ticker := time.NewTicker(period) + defer ticker.Stop() + for { + select { + case <-ticker.C: + c.mu.RLock() + conn := c.conn + c.mu.RUnlock() + if conn == nil { + return + } + if err := conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(writeDeadline)); err != nil { + // Write failure on a control frame means the connection + // is going down. Don't bother retrying — readMessages + // will hit the read deadline and tear down. + return + } + case <-c.ctx.Done(): + return + } + } +} + +func (c *Client) handshake() error { + roles := c.buildSupportedRoles() + + hello := ClientHello{ + ClientID: c.config.ClientID, + Name: c.config.Name, + Version: c.config.Version, + SupportedRoles: roles, + DeviceInfo: &c.config.DeviceInfo, + ArtworkV1Support: c.config.ArtworkV1Support, + VisualizerV1Support: c.config.VisualizerV1Support, + } + // Only advertise player@v1_support when player@v1 is in the role list. + // Strict peers (aiosendspin) reject a client/hello that carries a + // player@v1_support block without a matching role because the schema + // marks supported_formats as non-nullable, and a zero-value Go + // PlayerV1Support encodes its nil slice as JSON null. + if containsRole(roles, "player@v1") { + hello.PlayerV1Support = &c.config.PlayerV1Support + } + + msg := Message{ + Type: "client/hello", + Payload: hello, + } + + if err := c.sendJSON(msg); err != nil { + return fmt.Errorf("failed to send client/hello: %w", err) + } + + c.conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + _, data, err := c.conn.ReadMessage() + if err != nil { + return fmt.Errorf("failed to read server/hello: %w", err) + } + c.conn.SetReadDeadline(time.Time{}) // Clear deadline + + var serverMsg Message + if err := json.Unmarshal(data, &serverMsg); err != nil { + return fmt.Errorf("failed to parse server/hello: %w", err) + } + + if serverMsg.Type != "server/hello" { + return fmt.Errorf("expected server/hello, got %s", serverMsg.Type) + } + + // Parse the server hello payload into a typed struct and cache both + // the parsed form and the raw envelope bytes for later retrieval via + // ServerHello() / RawServerHello(). + payloadBytes, err := json.Marshal(serverMsg.Payload) + if err != nil { + return fmt.Errorf("failed to re-marshal server/hello payload: %w", err) + } + var parsedHello ServerHello + if err := json.Unmarshal(payloadBytes, &parsedHello); err != nil { + return fmt.Errorf("failed to decode server/hello payload: %w", err) + } + + c.mu.Lock() + c.serverHello = &parsedHello + c.rawServerHello = append([]byte(nil), data...) + c.mu.Unlock() + + log.Printf("Handshake complete with server") + + // Send initial state per spec (client/state with nested player object) + state := ClientStateMessage{ + Player: &PlayerState{ + State: "synchronized", + Volume: 100, + Muted: false, + }, + } + + stateMsg := Message{ + Type: "client/state", + Payload: state, + } + + if err := c.sendJSON(stateMsg); err != nil { + return fmt.Errorf("failed to send initial state: %w", err) + } + + return nil +} + +// ServerHello returns the parsed server/hello message received during +// handshake, or nil if handshake has not yet completed. Safe for concurrent +// reads; callers should not mutate the returned struct. +func (c *Client) ServerHello() *ServerHello { + c.mu.RLock() + defer c.mu.RUnlock() + return c.serverHello +} + +// RawServerHello returns the raw JSON envelope bytes of the server/hello +// message received during handshake, or nil if handshake has not yet +// completed. Useful for conformance testing and protocol debugging where +// the exact wire representation matters. Returns a fresh copy; callers +// may mutate it without affecting the client. +func (c *Client) RawServerHello() []byte { + c.mu.RLock() + defer c.mu.RUnlock() + if c.rawServerHello == nil { + return nil + } + out := make([]byte, len(c.rawServerHello)) + copy(out, c.rawServerHello) + return out +} + +// Send writes a typed envelope to the connected peer. The message is +// wrapped as {"type": msgType, "payload": payload} and sent as a text +// WebSocket frame. Use this for protocol messages the library does not +// yet have a dedicated sender for (e.g. client/command in controller +// scenarios). +func (c *Client) Send(msgType string, payload any) error { + return c.sendJSON(Message{Type: msgType, Payload: payload}) +} + +// containsRole reports whether the given role is in the list. +func containsRole(roles []string, role string) bool { + for _, r := range roles { + if r == role { + return true + } + } + return false +} + +// buildSupportedRoles returns the role list for the client/hello message. +// An explicit Config.SupportedRoles wins when set, since the caller is +// telling us exactly which roles to advertise (metadata-only, controller, +// etc.). Otherwise we build the default list from the V1 support fields: +// player@v1 and metadata@v1 are always advertised for backwards +// compatibility, and artwork@v1 / visualizer@v1 join the list when their +// support structs are set. +func (c *Client) buildSupportedRoles() []string { + if len(c.config.SupportedRoles) > 0 { + return c.config.SupportedRoles + } + roles := []string{"player@v1", "metadata@v1", "controller@v1"} + if c.config.ArtworkV1Support != nil { + roles = append(roles, "artwork@v1") + } + if c.config.VisualizerV1Support != nil { + roles = append(roles, "visualizer@v1") + } + return roles +} + +func (c *Client) sendJSON(msg Message) error { + c.mu.RLock() + defer c.mu.RUnlock() + + if !c.connected { + return fmt.Errorf("not connected") + } + + return c.conn.WriteJSON(msg) +} + +func (c *Client) readMessages() { + defer c.Close() + + for { + select { + case <-c.ctx.Done(): + return + default: + } + + messageType, data, err := c.conn.ReadMessage() + if err != nil { + log.Printf("Read error: %v", err) + return + } + + if messageType == websocket.BinaryMessage { + c.handleBinaryMessage(data) + } else if messageType == websocket.TextMessage { + c.handleJSONMessage(data) + } else { + log.Printf("Unknown WebSocket message type: %d", messageType) + } + } +} + +func (c *Client) handleBinaryMessage(data []byte) { + if len(data) < BinaryMessageHeaderSize { + log.Printf("Invalid binary message: too short") + return + } + + msgType := data[0] + timestamp := int64(binary.BigEndian.Uint64(data[1:BinaryMessageHeaderSize])) + payload := data[BinaryMessageHeaderSize:] + + switch { + case msgType == AudioChunkMessageType: + select { + case c.AudioChunks <- AudioChunk{Timestamp: timestamp, Data: payload}: + case <-c.ctx.Done(): + } + case msgType >= ArtworkChannel0MessageType && msgType <= ArtworkChannel3MessageType: + select { + case c.ArtworkChunks <- ArtworkChunk{ + Channel: int(msgType) - ArtworkChannel0MessageType, + Timestamp: timestamp, + Data: payload, + }: + case <-c.ctx.Done(): + } + default: + log.Printf("Unknown binary message type: %d", msgType) + } +} + +// handleJSONMessage routes JSON messages per spec +func (c *Client) handleJSONMessage(data []byte) { + var msg Message + if err := json.Unmarshal(data, &msg); err != nil { + log.Printf("Failed to parse JSON message: %v", err) + return + } + + payloadBytes, err := json.Marshal(msg.Payload) + if err != nil { + log.Printf("Failed to marshal payload for %s: %v", msg.Type, err) + return + } + + switch msg.Type { + case "server/command": + var cmdMsg ServerCommandMessage + if err := json.Unmarshal(payloadBytes, &cmdMsg); err != nil { + log.Printf("Failed to parse server/command: %v", err) + return + } + if cmdMsg.Player != nil { + select { + case c.ControlMsgs <- *cmdMsg.Player: + case <-c.ctx.Done(): + } + } + + case "server/time": + var timeMsg ServerTime + if err := json.Unmarshal(payloadBytes, &timeMsg); err != nil { + log.Printf("Failed to parse server/time: %v", err) + return + } + select { + case c.TimeSyncResp <- timeMsg: + case <-c.ctx.Done(): + } + + case "stream/start": + var start StreamStart + if err := json.Unmarshal(payloadBytes, &start); err != nil { + log.Printf("Failed to parse stream/start: %v", err) + return + } + select { + case c.StreamStart <- start: + case <-c.ctx.Done(): + } + + case "stream/clear": + var clear StreamClear + if err := json.Unmarshal(payloadBytes, &clear); err != nil { + log.Printf("Failed to parse stream/clear: %v", err) + return + } + select { + case c.StreamClear <- clear: + case <-c.ctx.Done(): + } + + case "stream/end": + var end StreamEnd + if err := json.Unmarshal(payloadBytes, &end); err != nil { + log.Printf("Failed to parse stream/end: %v", err) + return + } + select { + case c.StreamEnd <- end: + case <-c.ctx.Done(): + } + + case "server/state": + var state ServerStateMessage + if err := json.Unmarshal(payloadBytes, &state); err != nil { + log.Printf("Failed to parse server/state: %v", err) + return + } + if state.Metadata != nil { + log.Printf("Metadata: %v - %v (%v)", + derefString(state.Metadata.Artist), + derefString(state.Metadata.Title), + derefString(state.Metadata.Album)) + } + select { + case c.ServerState <- state: + case <-time.After(100 * time.Millisecond): + log.Printf("Server state channel full, dropping message") + } + + case "group/update": + var update GroupUpdate + if err := json.Unmarshal(payloadBytes, &update); err != nil { + log.Printf("Failed to parse group/update: %v", err) + return + } + log.Printf("Group update: id=%v, state=%v", + derefString(update.GroupID), + derefString(update.PlaybackState)) + select { + case c.GroupUpdate <- update: + case <-time.After(100 * time.Millisecond): + log.Printf("Group update channel full, dropping message") + } + + default: + log.Printf("Unknown message type: %s", msg.Type) + } +} + +func derefString(s *string) string { + if s == nil { + return "" + } + return *s +} + +// SendState sends a client/state message per spec +func (c *Client) SendState(state PlayerState) error { + msg := Message{ + Type: "client/state", + Payload: ClientStateMessage{ + Player: &state, + }, + } + return c.sendJSON(msg) +} + +// SendGoodbye sends a client/goodbye message before disconnecting +func (c *Client) SendGoodbye(reason string) error { + msg := Message{ + Type: "client/goodbye", + Payload: ClientGoodbye{ + Reason: reason, + }, + } + return c.sendJSON(msg) +} + +func (c *Client) SendTimeSync(t1 int64) error { + msg := Message{ + Type: "client/time", + Payload: ClientTime{ + ClientTransmitted: t1, + }, + } + return c.sendJSON(msg) +} + +func (c *Client) Close() { + c.mu.Lock() + defer c.mu.Unlock() + + if c.connected { + c.connected = false + c.cancel() + c.conn.Close() + log.Printf("Connection closed") + } +} + +// Done returns a channel that is closed when the client connection is lost. +func (c *Client) Done() <-chan struct{} { + return c.ctx.Done() +} + +func (c *Client) IsConnected() bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.connected +} diff --git a/third_party/sendspin-go/pkg/protocol/client_test.go b/third_party/sendspin-go/pkg/protocol/client_test.go new file mode 100644 index 0000000..7d228c3 --- /dev/null +++ b/third_party/sendspin-go/pkg/protocol/client_test.go @@ -0,0 +1,773 @@ +// ABOUTME: Tests for WebSocket client implementation +// ABOUTME: Tests connection, handshake, and message routing +package protocol + +import ( + "bytes" + "encoding/binary" + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" +) + +func TestNewClient(t *testing.T) { + config := Config{ + ServerAddr: "localhost:8927", + ClientID: "test-client", + Name: "Test Player", + } + + client := NewClient(config) + if client == nil { + t.Fatal("expected client to be created") + } + + if client.config.ServerAddr != "localhost:8927" { + t.Errorf("expected server addr localhost:8927, got %s", client.config.ServerAddr) + } + + if client.ArtworkChunks == nil { + t.Error("expected ArtworkChunks channel to be initialized") + } +} + +// buildBinaryFrame constructs a binary protocol frame matching what the server +// emits: [1 byte type][8 byte big-endian timestamp µs][payload]. +func buildBinaryFrame(msgType byte, timestamp int64, payload []byte) []byte { + frame := make([]byte, BinaryMessageHeaderSize+len(payload)) + frame[0] = msgType + binary.BigEndian.PutUint64(frame[1:BinaryMessageHeaderSize], uint64(timestamp)) + copy(frame[BinaryMessageHeaderSize:], payload) + return frame +} + +// recvWithTimeout waits briefly for a value on a channel. Returns the zero +// value + false if nothing arrives in time. +func recvAudioChunk(t *testing.T, ch <-chan AudioChunk) (AudioChunk, bool) { + t.Helper() + select { + case chunk := <-ch: + return chunk, true + case <-time.After(100 * time.Millisecond): + return AudioChunk{}, false + } +} + +func recvArtworkChunk(t *testing.T, ch <-chan ArtworkChunk) (ArtworkChunk, bool) { + t.Helper() + select { + case chunk := <-ch: + return chunk, true + case <-time.After(100 * time.Millisecond): + return ArtworkChunk{}, false + } +} + +func TestHandleBinaryMessage_AudioChunkRouting(t *testing.T) { + client := NewClient(Config{ServerAddr: "localhost:0", ClientID: "t", Name: "t"}) + payload := []byte{0x01, 0x02, 0x03, 0x04} + + client.handleBinaryMessage(buildBinaryFrame(AudioChunkMessageType, 123_456, payload)) + + chunk, ok := recvAudioChunk(t, client.AudioChunks) + if !ok { + t.Fatal("expected an AudioChunk on the channel") + } + if chunk.Timestamp != 123_456 { + t.Errorf("timestamp = %d, want 123456", chunk.Timestamp) + } + if string(chunk.Data) != string(payload) { + t.Errorf("data = %x, want %x", chunk.Data, payload) + } +} + +// TestHandleBinaryMessage_ArtworkRouting covers all four artwork channel IDs +// (types 8, 9, 10, 11 → channels 0, 1, 2, 3). Closes #27: "Unknown binary +// message type: 8" was the spec-defined ArtworkChannel0MessageType being +// silently dropped. +func TestHandleBinaryMessage_ArtworkRouting(t *testing.T) { + cases := []struct { + name string + msgType byte + channel int + }{ + {"channel 0", ArtworkChannel0MessageType, 0}, + {"channel 1", ArtworkChannel1MessageType, 1}, + {"channel 2", ArtworkChannel2MessageType, 2}, + {"channel 3", ArtworkChannel3MessageType, 3}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + client := NewClient(Config{ServerAddr: "localhost:0", ClientID: "t", Name: "t"}) + imageBytes := []byte("fake-jpeg-bytes-here") + timestamp := int64(999_888_777) + + client.handleBinaryMessage(buildBinaryFrame(tc.msgType, timestamp, imageBytes)) + + chunk, ok := recvArtworkChunk(t, client.ArtworkChunks) + if !ok { + t.Fatalf("expected an ArtworkChunk on the channel (msgType=%d)", tc.msgType) + } + if chunk.Channel != tc.channel { + t.Errorf("channel = %d, want %d", chunk.Channel, tc.channel) + } + if chunk.Timestamp != timestamp { + t.Errorf("timestamp = %d, want %d", chunk.Timestamp, timestamp) + } + if string(chunk.Data) != string(imageBytes) { + t.Errorf("data = %q, want %q", chunk.Data, imageBytes) + } + }) + } +} + +// TestHandleBinaryMessage_UnknownTypeLogged confirms unknown types are still +// dropped (and not routed anywhere) after the artwork additions. This guards +// the switch-default branch so a future add-without-test doesn't turn a +// real bug into silent mis-routing. +func TestHandleBinaryMessage_UnknownTypeLogged(t *testing.T) { + client := NewClient(Config{ServerAddr: "localhost:0", ClientID: "t", Name: "t"}) + + // Type 99 is not defined anywhere in the spec. + client.handleBinaryMessage(buildBinaryFrame(99, 0, []byte{0xff})) + + if _, ok := recvAudioChunk(t, client.AudioChunks); ok { + t.Error("unknown type was routed to AudioChunks") + } + if _, ok := recvArtworkChunk(t, client.ArtworkChunks); ok { + t.Error("unknown type was routed to ArtworkChunks") + } +} + +// TestBuildSupportedRoles_Default covers the old auto-built path. Keep this +// test lean; a table test for every permutation of the four V1 support +// fields is over-investment for what's essentially a handful of if +// statements. +func TestBuildSupportedRoles_Default(t *testing.T) { + cases := []struct { + name string + config Config + want []string + }{ + { + name: "bare default is player+metadata+controller", + config: Config{}, + want: []string{"player@v1", "metadata@v1", "controller@v1"}, + }, + { + name: "artwork support adds artwork@v1", + config: Config{ArtworkV1Support: &ArtworkV1Support{}}, + want: []string{"player@v1", "metadata@v1", "controller@v1", "artwork@v1"}, + }, + { + name: "visualizer support adds visualizer@v1", + config: Config{VisualizerV1Support: &VisualizerV1Support{}}, + want: []string{"player@v1", "metadata@v1", "controller@v1", "visualizer@v1"}, + }, + { + name: "both support structs set", + config: Config{ + ArtworkV1Support: &ArtworkV1Support{}, + VisualizerV1Support: &VisualizerV1Support{}, + }, + want: []string{"player@v1", "metadata@v1", "controller@v1", "artwork@v1", "visualizer@v1"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + client := NewClient(tc.config) + got := client.buildSupportedRoles() + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("buildSupportedRoles() = %v, want %v", got, tc.want) + } + }) + } +} + +// TestBuildSupportedRoles_ExplicitOverride verifies that Config.SupportedRoles +// wins over the V1 support struct auto-build. This is the load-bearing +// behavior for conformance metadata-only and controller scenarios that +// need to NOT advertise player@v1. +func TestBuildSupportedRoles_ExplicitOverride(t *testing.T) { + cases := []struct { + name string + config Config + want []string + }{ + { + name: "controller only", + config: Config{SupportedRoles: []string{"controller@v1"}}, + want: []string{"controller@v1"}, + }, + { + name: "metadata only", + config: Config{SupportedRoles: []string{"metadata@v1"}}, + want: []string{"metadata@v1"}, + }, + { + // Confirms that setting ArtworkV1Support alongside an override + // does NOT inject artwork@v1 — the caller's override is canon. + name: "override wins over artwork support struct", + config: Config{ + SupportedRoles: []string{"player@v1"}, + ArtworkV1Support: &ArtworkV1Support{}, + }, + want: []string{"player@v1"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + client := NewClient(tc.config) + got := client.buildSupportedRoles() + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("buildSupportedRoles() = %v, want %v", got, tc.want) + } + }) + } +} + +// TestNewClientFromConn_HandshakeAndClose spins up a local WebSocket server +// that plays the Sendspin handshake dance (read client/hello, send +// server/hello, read client/state, close). The test then uses +// NewClientFromConn + Start to drive the client side over an accepted +// connection, proving that server-initiated scenarios can use the library +// without hand-rolling the message loop. +func TestNewClientFromConn_HandshakeAndClose(t *testing.T) { + // Capture the roles the client advertises so we can assert the override + // plumbed through to the wire. + capturedHello := make(chan ClientHello, 1) + + upgrader := websocket.Upgrader{ + CheckOrigin: func(*http.Request) bool { return true }, + } + handler := func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Errorf("upgrade: %v", err) + return + } + defer conn.Close() + + // Read client/hello. + _, helloBytes, err := conn.ReadMessage() + if err != nil { + t.Errorf("read client/hello: %v", err) + return + } + var envelope Message + if err := json.Unmarshal(helloBytes, &envelope); err != nil { + t.Errorf("unmarshal client/hello: %v", err) + return + } + payloadBytes, _ := json.Marshal(envelope.Payload) + var hello ClientHello + _ = json.Unmarshal(payloadBytes, &hello) + capturedHello <- hello + + // Send server/hello. + serverHello := Message{ + Type: "server/hello", + Payload: ServerHello{ + ServerID: "test-server", + Name: "Test Server", + Version: 1, + ActiveRoles: hello.SupportedRoles, + }, + } + if err := conn.WriteJSON(serverHello); err != nil { + t.Errorf("write server/hello: %v", err) + return + } + + // Read client/state (the library sends this immediately after + // a successful handshake — see handshake() in client.go). + if _, _, err := conn.ReadMessage(); err != nil { + t.Errorf("read client/state: %v", err) + return + } + + // Hold the connection open briefly so the client's read loop has + // something to read, then close. + time.Sleep(50 * time.Millisecond) + } + + server := httptest.NewServer(http.HandlerFunc(handler)) + defer server.Close() + + // Dial it ourselves to simulate a server-initiated scenario where the + // caller has an accepted *websocket.Conn and hands it to the library. + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + + client := NewClientFromConn(Config{ + ClientID: "test-from-conn", + Name: "FromConn Test", + Version: 1, + SupportedRoles: []string{"controller@v1"}, + }, conn) + defer client.Close() + + if err := client.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + + select { + case hello := <-capturedHello: + if !reflect.DeepEqual(hello.SupportedRoles, []string{"controller@v1"}) { + t.Errorf("server saw SupportedRoles = %v, want [controller@v1]", hello.SupportedRoles) + } + if hello.ClientID != "test-from-conn" { + t.Errorf("server saw ClientID = %q, want test-from-conn", hello.ClientID) + } + case <-time.After(2 * time.Second): + t.Fatal("server never captured a client/hello") + } + + // ServerHello() and RawServerHello() must return the handshake data + // after Start() completes. The test server above responds with a hello + // whose ServerID is "test-server" and Name is "Test Server", so both + // accessors should agree. + parsed := client.ServerHello() + if parsed == nil { + t.Fatal("ServerHello() returned nil after successful handshake") + } + if parsed.ServerID != "test-server" { + t.Errorf("ServerHello().ServerID = %q, want test-server", parsed.ServerID) + } + if parsed.Name != "Test Server" { + t.Errorf("ServerHello().Name = %q, want Test Server", parsed.Name) + } + + raw := client.RawServerHello() + if len(raw) == 0 { + t.Fatal("RawServerHello() returned empty bytes after successful handshake") + } + var envelope map[string]any + if err := json.Unmarshal(raw, &envelope); err != nil { + t.Fatalf("RawServerHello() did not return valid JSON: %v", err) + } + if envelope["type"] != "server/hello" { + t.Errorf("raw envelope type = %v, want server/hello", envelope["type"]) + } + + // Confirm RawServerHello returns a copy: mutating the returned slice + // must not affect subsequent calls. + if len(raw) > 0 { + raw[0] = 0xFF + } + raw2 := client.RawServerHello() + if bytes.Equal(raw, raw2) { + t.Error("RawServerHello returned a shared reference; mutation leaked into the client") + } +} + +// TestStart_NoConnection confirms Start refuses to run without a connection +// in place. Prevents a future caller from assuming Start does its own dialing. +func TestStart_NoConnection(t *testing.T) { + client := NewClient(Config{ServerAddr: "localhost:0", ClientID: "t", Name: "t"}) + err := client.Start() + if err == nil { + t.Fatal("expected error when Start is called with no connection") + } + if !strings.Contains(err.Error(), "no connection") { + t.Errorf("error message = %q, want substring %q", err.Error(), "no connection") + } +} + +// TestServerHello_BeforeHandshake guards against nil confusion: the +// accessors must return nil/empty before Start() runs, not stale data +// from a previous client or a zero-value ServerHello. +func TestServerHello_BeforeHandshake(t *testing.T) { + client := NewClient(Config{ServerAddr: "localhost:0", ClientID: "t", Name: "t"}) + + if hello := client.ServerHello(); hello != nil { + t.Errorf("ServerHello() before handshake = %+v, want nil", hello) + } + if raw := client.RawServerHello(); raw != nil { + t.Errorf("RawServerHello() before handshake = %v, want nil", raw) + } +} + +// TestClientSend_WritesEnvelope verifies that Client.Send emits a correctly +// shaped {"type": ..., "payload": ...} envelope on the wire. The test +// server reads a client/command message after handshake and asserts on the +// envelope shape, which is exactly what the conformance adapter's +// controller scenarios need. +func TestClientSend_WritesEnvelope(t *testing.T) { + capturedCommand := make(chan map[string]any, 1) + + upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} + handler := func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Errorf("upgrade: %v", err) + return + } + defer conn.Close() + + // Handshake: read client/hello, write server/hello, read client/state. + if _, _, err := conn.ReadMessage(); err != nil { + t.Errorf("read client/hello: %v", err) + return + } + if err := conn.WriteJSON(Message{ + Type: "server/hello", + Payload: ServerHello{ServerID: "srv", Name: "srv", Version: 1}, + }); err != nil { + t.Errorf("write server/hello: %v", err) + return + } + if _, _, err := conn.ReadMessage(); err != nil { + t.Errorf("read client/state: %v", err) + return + } + + // Now read the client/command the test below will send via Send(). + _, cmdBytes, err := conn.ReadMessage() + if err != nil { + t.Errorf("read client/command: %v", err) + return + } + var envelope map[string]any + if err := json.Unmarshal(cmdBytes, &envelope); err != nil { + t.Errorf("unmarshal envelope: %v", err) + return + } + capturedCommand <- envelope + + time.Sleep(20 * time.Millisecond) + } + + server := httptest.NewServer(http.HandlerFunc(handler)) + defer server.Close() + + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + + client := NewClientFromConn(Config{ + ClientID: "sender", + Name: "Sender Test", + Version: 1, + SupportedRoles: []string{"controller@v1"}, + }, conn) + defer client.Close() + + if err := client.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + + payload := map[string]any{"controller": map[string]any{"command": "next"}} + if err := client.Send("client/command", payload); err != nil { + t.Fatalf("Send: %v", err) + } + + select { + case envelope := <-capturedCommand: + if envelope["type"] != "client/command" { + t.Errorf("envelope.type = %v, want client/command", envelope["type"]) + } + innerPayload, ok := envelope["payload"].(map[string]any) + if !ok { + t.Fatalf("envelope.payload not a map: %v", envelope["payload"]) + } + controller, ok := innerPayload["controller"].(map[string]any) + if !ok { + t.Fatalf("payload.controller not a map: %v", innerPayload["controller"]) + } + if controller["command"] != "next" { + t.Errorf("controller.command = %v, want next", controller["command"]) + } + case <-time.After(2 * time.Second): + t.Fatal("server never captured a client/command") + } +} + +// TestClient_DetectsHalfOpenConnection verifies that the client tears down +// (Done() fires) when the server stops responding to WebSocket control +// pings. Without the heartbeat fix, ReadMessage blocks forever after a +// silently-dropped connection (NAT timeout, idle eviction, missed RST) and +// Done() never closes — the symptom Chris saw in the field as "Burst sample +// N/8 timed out" with no reconnect. +// +// The fake server completes the Sendspin handshake and then installs a +// no-op PingHandler so the client's pings are read but never elicit a Pong. +// The client's pongWait deadline expires, ReadMessage errors out, Close +// runs from readMessages's defer, and Done() fires. +func TestClient_DetectsHalfOpenConnection(t *testing.T) { + // Override the package-level heartbeat vars to test-friendly values + // so the test wallclock budget is well under a second. Restore via + // t.Cleanup so other tests in this binary keep production timings. + savedPingPeriod, savedPongWait, savedWriteWait := pingPeriod, pongWait, writeWait + pingPeriod = 50 * time.Millisecond + pongWait = 250 * time.Millisecond + writeWait = 100 * time.Millisecond + t.Cleanup(func() { + pingPeriod = savedPingPeriod + pongWait = savedPongWait + writeWait = savedWriteWait + }) + + upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} + serverDone := make(chan struct{}) + handler := func(w http.ResponseWriter, r *http.Request) { + defer close(serverDone) + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Errorf("upgrade: %v", err) + return + } + defer conn.Close() + + // Override the default PingHandler so the server does NOT auto-pong. + // This is the half-open simulation: pings arrive but no pong reply + // goes back. Returning nil keeps ReadMessage from surfacing an error, + // so the server happily reads forever while the client's read deadline + // counts down on the other side. + conn.SetPingHandler(func(string) error { return nil }) + + // Sendspin handshake: client/hello → server/hello → client/state. + if _, _, err := conn.ReadMessage(); err != nil { + t.Errorf("read client/hello: %v", err) + return + } + if err := conn.WriteJSON(Message{ + Type: "server/hello", + Payload: ServerHello{ServerID: "srv", Name: "srv", Version: 1}, + }); err != nil { + t.Errorf("write server/hello: %v", err) + return + } + if _, _, err := conn.ReadMessage(); err != nil { + t.Errorf("read client/state: %v", err) + return + } + + // Drain any further frames (the client's pings) until the connection + // errors out from the client side. We don't pong, so the client's + // read deadline will fire and it will close the socket. + for { + if _, _, err := conn.ReadMessage(); err != nil { + return + } + } + } + + server := httptest.NewServer(http.HandlerFunc(handler)) + defer server.Close() + + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + + client := NewClientFromConn(Config{ + ClientID: "half-open-test", + Name: "Half Open Test", + Version: 1, + SupportedRoles: []string{"controller@v1"}, + }, conn) + defer client.Close() + + if err := client.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + + // pongWait + slack. If Done() doesn't fire, the heartbeat is broken. + select { + case <-client.Done(): + case <-time.After(750 * time.Millisecond): + t.Fatal("Done() did not fire within pongWait + slack; half-open connection went undetected") + } + + // Idempotent shutdown: calling Close again must not panic. + client.Close() + + // Let the server goroutine unwind so the test doesn't leak it. + select { + case <-serverDone: + case <-time.After(500 * time.Millisecond): + t.Log("server handler did not unwind in time (not fatal; client side already verified)") + } +} + +// TestHandshake_PlayerSupportGatedByRoles is a regression test for a real +// bug caught by the conformance harness against aiosendspin. The Go +// library used to emit player@v1_support in every client/hello regardless +// of the advertised role list. aiosendspin's schema marks +// ClientHelloPlayerSupport.supported_formats as non-nullable, so a zero- +// value Go PlayerV1Support (whose nil slice encodes as JSON null) caused +// aiosendspin's mashumaro deserializer to reject the entire hello and +// close the connection with code 1000. sendspin-go then reported +// "handshake failed: failed to read server/hello: websocket: close 1000". +// +// The fix: only set hello.PlayerV1Support when player@v1 is in the +// final advertised role list. This mirrors how ArtworkV1Support and +// VisualizerV1Support were already being handled (only set when +// non-nil in config), and aligns with aiosendspin's own validation +// comment: "player@v1_support must be provided when 'player@v1' is in +// supported_roles". +// +// The test captures the raw hello bytes on the wire and asserts the +// top-level payload key is absent for non-player roles, and present +// for player@v1 (the default). +func TestHandshake_PlayerSupportGatedByRoles(t *testing.T) { + // playerSupport is populated for the "advertised player" cases so the + // emitted player@v1_support block is a well-formed one — matches what + // a real caller would pass — and the secondary null-check assertion + // below is meaningful rather than a false positive. + playerSupport := PlayerV1Support{ + SupportedFormats: []AudioFormat{ + {Codec: "pcm", Channels: 2, SampleRate: 48000, BitDepth: 16}, + }, + BufferCapacity: 1_000_000, + SupportedCommands: []string{"volume", "mute"}, + } + + cases := []struct { + name string + supportedRoles []string + playerV1Support PlayerV1Support + wantPlayerKey bool + }{ + { + name: "controller only omits player support", + supportedRoles: []string{"controller@v1"}, + wantPlayerKey: false, + }, + { + name: "metadata only omits player support", + supportedRoles: []string{"metadata@v1"}, + wantPlayerKey: false, + }, + { + name: "artwork only omits player support", + supportedRoles: []string{"artwork@v1"}, + wantPlayerKey: false, + }, + { + name: "player advertised keeps player support", + supportedRoles: []string{"player@v1"}, + playerV1Support: playerSupport, + wantPlayerKey: true, + }, + { + name: "player plus other roles keeps player support", + supportedRoles: []string{"player@v1", "controller@v1"}, + playerV1Support: playerSupport, + wantPlayerKey: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + capturedHello := make(chan []byte, 1) + + upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} + handler := func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Errorf("upgrade: %v", err) + return + } + defer conn.Close() + + _, helloBytes, err := conn.ReadMessage() + if err != nil { + t.Errorf("read client/hello: %v", err) + return + } + // Copy the slice; gorilla reuses its underlying buffer. + snapshot := make([]byte, len(helloBytes)) + copy(snapshot, helloBytes) + capturedHello <- snapshot + + // Drive the rest of the handshake so the client goroutine + // doesn't error out on a hard close mid-stream. + _ = conn.WriteJSON(Message{ + Type: "server/hello", + Payload: ServerHello{ServerID: "srv", Name: "srv", Version: 1}, + }) + _, _, _ = conn.ReadMessage() // client/state + time.Sleep(20 * time.Millisecond) + } + + server := httptest.NewServer(http.HandlerFunc(handler)) + defer server.Close() + + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + + client := NewClientFromConn(Config{ + ClientID: "test", + Name: "Test", + Version: 1, + SupportedRoles: tc.supportedRoles, + PlayerV1Support: tc.playerV1Support, + }, conn) + defer client.Close() + + if err := client.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + + var helloBytes []byte + select { + case helloBytes = <-capturedHello: + case <-time.After(2 * time.Second): + t.Fatal("server never captured a client/hello") + } + + var envelope map[string]any + if err := json.Unmarshal(helloBytes, &envelope); err != nil { + t.Fatalf("unmarshal envelope: %v", err) + } + payload, ok := envelope["payload"].(map[string]any) + if !ok { + t.Fatalf("envelope.payload is not a map: %v", envelope["payload"]) + } + + _, havePlayerKey := payload["player@v1_support"] + if havePlayerKey != tc.wantPlayerKey { + if tc.wantPlayerKey { + t.Errorf("client/hello missing player@v1_support when player@v1 is advertised:\n%s", helloBytes) + } else { + t.Errorf("client/hello includes player@v1_support when player@v1 is NOT advertised (%v):\n%s", + tc.supportedRoles, helloBytes) + } + } + + // When PlayerSupport is emitted and its supported_formats is nil, + // the JSON encoder produces null. Catch that too — a nil-slice + // null would still fail against aiosendspin's schema even if + // we correctly gated on roles. + if havePlayerKey { + playerSupport, ok := payload["player@v1_support"].(map[string]any) + if !ok { + t.Fatalf("player@v1_support is not a map: %v", payload["player@v1_support"]) + } + if playerSupport["supported_formats"] == nil { + t.Error("player@v1_support.supported_formats serialized as null; aiosendspin's schema rejects this") + } + } + }) + } +} diff --git a/third_party/sendspin-go/pkg/protocol/doc.go b/third_party/sendspin-go/pkg/protocol/doc.go new file mode 100644 index 0000000..6d9ae6f --- /dev/null +++ b/third_party/sendspin-go/pkg/protocol/doc.go @@ -0,0 +1,12 @@ +// ABOUTME: Resonate wire protocol package +// ABOUTME: Defines protocol messages and WebSocket client +// Package protocol implements the Resonate wire protocol. +// +// Provides message types and WebSocket client for communicating +// with Resonate servers. +// +// Example: +// +// client, err := protocol.NewClient("localhost:8927") +// err = client.SendHello(helloMsg) +package protocol diff --git a/third_party/sendspin-go/pkg/protocol/messages.go b/third_party/sendspin-go/pkg/protocol/messages.go new file mode 100644 index 0000000..6a6714c --- /dev/null +++ b/third_party/sendspin-go/pkg/protocol/messages.go @@ -0,0 +1,330 @@ +// ABOUTME: Sendspin Protocol message type definitions +// ABOUTME: Defines structs for all message types per the Sendspin spec +package protocol + +import "encoding/json" + +// Message is the top-level wrapper for all protocol messages +type Message struct { + Type string `json:"type"` + Payload interface{} `json:"payload"` +} + +// ClientHello is sent by clients to initiate the handshake +// Per spec: roles use versioned format like "player@v1" +type ClientHello struct { + ClientID string `json:"client_id"` + Name string `json:"name"` + Version int `json:"version"` + SupportedRoles []string `json:"supported_roles"` + DeviceInfo *DeviceInfo `json:"device_info,omitempty"` + // Per spec: support objects use versioned keys like "player@v1_support" + PlayerV1Support *PlayerV1Support `json:"player@v1_support,omitempty"` + ArtworkV1Support *ArtworkV1Support `json:"artwork@v1_support,omitempty"` + VisualizerV1Support *VisualizerV1Support `json:"visualizer@v1_support,omitempty"` + + // Legacy support fields for Music Assistant backward compatibility + // Uses unversioned keys like "player_support" instead of "player@v1_support" + PlayerSupport *PlayerSupport `json:"player_support,omitempty"` + MetadataSupport *MetadataSupport `json:"metadata_support,omitempty"` + ArtworkSupport *ArtworkSupport `json:"artwork_support,omitempty"` + VisualizerSupport *VisualizerSupport `json:"visualizer_support,omitempty"` +} + +type DeviceInfo struct { + ProductName string `json:"product_name"` + Manufacturer string `json:"manufacturer"` + SoftwareVersion string `json:"software_version"` +} + +// PlayerV1Support describes player@v1 capabilities per spec +type PlayerV1Support struct { + SupportedFormats []AudioFormat `json:"supported_formats"` + BufferCapacity int `json:"buffer_capacity"` + SupportedCommands []string `json:"supported_commands"` + + // Legacy fields for Music Assistant backward compatibility + // MA uses separate arrays instead of AudioFormat objects + SupportCodecs []string `json:"support_codecs,omitempty"` + SupportChannels []int `json:"support_channels,omitempty"` + SupportSampleRates []int `json:"support_sample_rates,omitempty"` + SupportBitDepth []int `json:"support_bit_depth,omitempty"` +} + +// ArtworkV1Support describes artwork@v1 capabilities per spec +type ArtworkV1Support struct { + Channels []ArtworkChannel `json:"channels"` +} + +type ArtworkChannel struct { + Source string `json:"source"` // "album", "artist", or "none" + Format string `json:"format"` // "jpeg", "png", or "bmp" + MediaWidth int `json:"media_width"` + MediaHeight int `json:"media_height"` +} + +// VisualizerV1Support describes visualizer@v1 capabilities per spec +type VisualizerV1Support struct { + BufferCapacity int `json:"buffer_capacity"` +} + +type AudioFormat struct { + Codec string `json:"codec"` + Channels int `json:"channels"` + SampleRate int `json:"sample_rate"` + BitDepth int `json:"bit_depth"` +} + +// ServerHello is the server's response to client/hello +type ServerHello struct { + ServerID string `json:"server_id"` + Name string `json:"name"` + Version int `json:"version"` + ActiveRoles []string `json:"active_roles"` + ConnectionReason string `json:"connection_reason"` // "discovery" or "playback" +} + +// ClientStateMessage is sent as client/state with role-specific objects +type ClientStateMessage struct { + Player *PlayerState `json:"player,omitempty"` +} + +// PlayerState reports the player's current state per spec +type PlayerState struct { + State string `json:"state"` // "synchronized" or "error" + Volume int `json:"volume,omitempty"` // 0-100, if volume command supported + Muted bool `json:"muted,omitempty"` // if mute command supported +} + +// ServerCommandMessage is sent as server/command with role-specific objects +type ServerCommandMessage struct { + Player *PlayerCommand `json:"player,omitempty"` +} + +type PlayerCommand struct { + Command string `json:"command"` // "volume" or "mute" + Volume int `json:"volume,omitempty"` + Mute bool `json:"mute,omitempty"` +} + +type StreamStartPlayer struct { + Codec string `json:"codec"` + SampleRate int `json:"sample_rate"` + Channels int `json:"channels"` + BitDepth int `json:"bit_depth"` + CodecHeader string `json:"codec_header,omitempty"` // Base64-encoded +} + +// StreamStartArtwork describes the artwork channels a server is about to send. +// Distinct from ArtworkV1Support (client hello): the hello advertises what the +// client can accept (media_width/height), while this describes the actual +// dimensions of the image about to be streamed (width/height). +type StreamStartArtwork struct { + Channels []ArtworkStreamChannel `json:"channels"` +} + +// ArtworkStreamChannel is a single channel entry inside StreamStartArtwork. +type ArtworkStreamChannel struct { + Source string `json:"source"` // "album", "artist", etc. + Format string `json:"format"` // "jpeg", "png", "bmp" + Width int `json:"width"` + Height int `json:"height"` +} + +// StreamStart notifies the client of stream format (nested structure). +// Player and Artwork fields are independent — a server may send either or both. +type StreamStart struct { + Player *StreamStartPlayer `json:"player,omitempty"` + Artwork *StreamStartArtwork `json:"artwork,omitempty"` +} + +// ServerStateMessage is sent as server/state with role-specific objects +type ServerStateMessage struct { + Metadata *MetadataState `json:"metadata,omitempty"` + Controller *ControllerState `json:"controller,omitempty"` +} + +// MetadataState contains track metadata per spec (for metadata role). +// +// The wire encoding is tristate: an omitted key means "unchanged, preserve +// prior value", a JSON null means "explicitly clear", and a value means +// "set". Receivers must distinguish all three to merge a diff_update onto a +// running snapshot — see HasField. +type MetadataState struct { + Timestamp int64 `json:"timestamp"` // Server clock µs when valid + Title *string `json:"title,omitempty"` // Track title + Artist *string `json:"artist,omitempty"` // Primary artist(s) + AlbumArtist *string `json:"album_artist,omitempty"` // Album artist(s) + Album *string `json:"album,omitempty"` // Album name + ArtworkURL *string `json:"artwork_url,omitempty"` // URL to artwork + Year *int `json:"year,omitempty"` // Release year YYYY + Track *int `json:"track,omitempty"` // Track number (1-indexed) + Progress *ProgressState `json:"progress,omitempty"` // Playback progress + Repeat *string `json:"repeat,omitempty"` // "off", "one", "all" + Shuffle *bool `json:"shuffle,omitempty"` // Shuffle enabled + + // presentKeys records which JSON keys appeared in the incoming message, + // so callers can distinguish "field omitted" (preserve prior value) + // from "field set to null" (clear prior value). Populated by + // UnmarshalJSON; nil for messages constructed in Go directly. + presentKeys map[string]struct{} +} + +// HasField reports whether the named JSON key was present in the incoming +// server/state message. The check is case-sensitive against the wire name +// (e.g. "artwork_url", not "ArtworkURL"). Returns true for messages +// constructed in-process via field assignment (not through UnmarshalJSON), +// so backwards-compatible callers that build a MetadataState directly are +// treated as if every assigned field were "present". +func (m *MetadataState) HasField(jsonKey string) bool { + if m.presentKeys == nil { + return true + } + _, ok := m.presentKeys[jsonKey] + return ok +} + +// UnmarshalJSON decodes a MetadataState while tracking which JSON keys +// were present. Required to distinguish "omitted" (preserve prior value) +// from "null" (clear prior value) per the Sendspin metadata diff-update +// protocol — both decode to a nil pointer otherwise, which loses the +// signal. +func (m *MetadataState) UnmarshalJSON(data []byte) error { + // First pass: capture key presence. + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + // Second pass: decode known fields. The `type alias` indirection is the + // standard Go trick to avoid infinite recursion: json.Unmarshal on the + // alias bypasses our custom UnmarshalJSON because the alias type does + // not inherit methods. + type alias MetadataState + var a alias + if err := json.Unmarshal(data, &a); err != nil { + return err + } + + *m = MetadataState(a) + m.presentKeys = make(map[string]struct{}, len(raw)) + for k := range raw { + m.presentKeys[k] = struct{}{} + } + return nil +} + +// ProgressState contains playback progress info per spec +type ProgressState struct { + TrackProgress int `json:"track_progress"` // Current position in ms + TrackDuration int `json:"track_duration"` // Total duration in ms (0 = unknown) + PlaybackSpeed int `json:"playback_speed"` // Speed * 1000 (1000 = normal, 0 = paused) +} + +// ControllerState contains controller state per spec +type ControllerState struct { + SupportedCommands []string `json:"supported_commands"` + Volume int `json:"volume"` // Group volume 0-100 + Muted bool `json:"muted"` // Group mute state +} + +// GroupUpdate is sent as group/update per spec +type GroupUpdate struct { + PlaybackState *string `json:"playback_state,omitempty"` // "playing", "paused", "stopped" + GroupID *string `json:"group_id,omitempty"` + GroupName *string `json:"group_name,omitempty"` +} + +// StreamClear instructs clients to clear buffers (for seek) +type StreamClear struct { + Roles []string `json:"roles,omitempty"` // Roles to clear: "player", "visualizer" +} + +// StreamEnd ends streams for specified roles +type StreamEnd struct { + Roles []string `json:"roles,omitempty"` // Roles to end (omit = all) +} + +// ClientGoodbye is sent before graceful disconnect +type ClientGoodbye struct { + Reason string `json:"reason"` // "another_server", "shutdown", "restart", "user_request" +} + +// ClientTime is sent for clock synchronization +type ClientTime struct { + ClientTransmitted int64 `json:"client_transmitted"` // Client timestamp in microseconds +} + +// ServerTime is the response to client/time +type ServerTime struct { + ClientTransmitted int64 `json:"client_transmitted"` // Echoed client timestamp + ServerReceived int64 `json:"server_received"` // Server receive timestamp + ServerTransmitted int64 `json:"server_transmitted"` // Server send timestamp +} + +// Legacy types for Music Assistant backward compatibility + +// MetadataSupport describes metadata/artwork capabilities (legacy format) +type MetadataSupport struct { + SupportPictureFormats []string `json:"support_picture_formats"` + MediaWidth int `json:"media_width,omitempty"` + MediaHeight int `json:"media_height,omitempty"` +} + +// StreamMetadata contains track information (legacy message type) +type StreamMetadata struct { + Title string `json:"title,omitempty"` + Artist string `json:"artist,omitempty"` + Album string `json:"album,omitempty"` + ArtworkURL string `json:"artwork_url,omitempty"` +} + +// SessionMetadata contains track metadata within session updates (legacy format) +type SessionMetadata struct { + Title string `json:"title,omitempty"` + Artist string `json:"artist,omitempty"` + Album string `json:"album,omitempty"` + AlbumArtist string `json:"album_artist,omitempty"` + ArtworkURL string `json:"artwork_url,omitempty"` + Track int `json:"track,omitempty"` + TrackDuration int `json:"track_duration,omitempty"` + Year int `json:"year,omitempty"` + PlaybackSpeed float64 `json:"playback_speed,omitempty"` + Repeat string `json:"repeat,omitempty"` + Shuffle bool `json:"shuffle,omitempty"` + Timestamp int64 `json:"timestamp,omitempty"` +} + +// SessionUpdate notifies client of session state changes (legacy message type) +type SessionUpdate struct { + GroupID string `json:"group_id"` + PlaybackState string `json:"playback_state,omitempty"` // "playing" or "idle" + Metadata *SessionMetadata `json:"metadata,omitempty"` +} + +// VisualizerSupport is a legacy alias for VisualizerV1Support +type VisualizerSupport = VisualizerV1Support + +// ArtworkSupport is a legacy alias for ArtworkV1Support +type ArtworkSupport = ArtworkV1Support + +// ClientState is a legacy alias for PlayerState (flat format used with client/state) +type ClientState struct { + State string `json:"state"` // "synchronized" or "error" + Volume int `json:"volume"` // 0-100 + Muted bool `json:"muted"` +} + +// ServerCommand is a legacy flat format for player commands +type ServerCommand struct { + Command string `json:"command"` + Volume int `json:"volume,omitempty"` + Mute bool `json:"mute,omitempty"` +} + +// PlayerSupport is a format for player capabilities (Music Assistant / aiosendspin compatibility) +type PlayerSupport struct { + SupportedFormats []AudioFormat `json:"supported_formats,omitempty"` + BufferCapacity int `json:"buffer_capacity,omitempty"` + SupportedCommands []string `json:"supported_commands,omitempty"` +} diff --git a/third_party/sendspin-go/pkg/protocol/messages_test.go b/third_party/sendspin-go/pkg/protocol/messages_test.go new file mode 100644 index 0000000..f0d9ef4 --- /dev/null +++ b/third_party/sendspin-go/pkg/protocol/messages_test.go @@ -0,0 +1,336 @@ +// ABOUTME: Tests for Sendspin Protocol message types +// ABOUTME: Verifies JSON marshaling/unmarshaling of protocol messages +package protocol + +import ( + "encoding/json" + "testing" +) + +func TestClientHelloMarshaling(t *testing.T) { + hello := ClientHello{ + ClientID: "test-id", + Name: "Test Player", + Version: 1, + SupportedRoles: []string{"player@v1"}, + DeviceInfo: &DeviceInfo{ + ProductName: "Test Product", + Manufacturer: "Test Mfg", + SoftwareVersion: "0.1.0", + }, + PlayerV1Support: &PlayerV1Support{ + SupportedFormats: []AudioFormat{ + {Codec: "opus", Channels: 2, SampleRate: 48000, BitDepth: 16}, + {Codec: "flac", Channels: 2, SampleRate: 48000, BitDepth: 16}, + {Codec: "pcm", Channels: 2, SampleRate: 48000, BitDepth: 16}, + }, + BufferCapacity: 1048576, + SupportedCommands: []string{"volume", "mute"}, + }, + } + + msg := Message{ + Type: "client/hello", + Payload: hello, + } + + data, err := json.Marshal(msg) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + + var decoded Message + err = json.Unmarshal(data, &decoded) + if err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if decoded.Type != "client/hello" { + t.Errorf("expected type client/hello, got %s", decoded.Type) + } +} + +func TestClientStateMarshaling(t *testing.T) { + state := ClientStateMessage{ + Player: &PlayerState{ + State: "synchronized", + Volume: 80, + Muted: false, + }, + } + + msg := Message{ + Type: "client/state", + Payload: state, + } + + data, err := json.Marshal(msg) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + + var decoded Message + err = json.Unmarshal(data, &decoded) + if err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if decoded.Type != "client/state" { + t.Errorf("expected type client/state, got %s", decoded.Type) + } +} + +func TestServerHelloMarshaling(t *testing.T) { + hello := ServerHello{ + ServerID: "server-123", + Name: "Test Server", + Version: 1, + ActiveRoles: []string{"player@v1", "metadata@v1"}, + ConnectionReason: "playback", + } + + msg := Message{ + Type: "server/hello", + Payload: hello, + } + + data, err := json.Marshal(msg) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + + var decoded Message + err = json.Unmarshal(data, &decoded) + if err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if decoded.Type != "server/hello" { + t.Errorf("expected type server/hello, got %s", decoded.Type) + } +} + +// TestStreamStartArtwork_RoundTrip verifies the StreamStart message can carry +// an artwork channel list alongside (or instead of) the player field. The +// wire format must use width/height (not media_width/media_height, which is +// the hello-message convention). +func TestStreamStartArtwork_RoundTrip(t *testing.T) { + original := StreamStart{ + Artwork: &StreamStartArtwork{ + Channels: []ArtworkStreamChannel{ + {Source: "album", Format: "jpeg", Width: 256, Height: 256}, + }, + }, + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + // Spot-check the wire shape matches what peers expect. + want := `{"artwork":{"channels":[{"source":"album","format":"jpeg","width":256,"height":256}]}}` + if string(data) != want { + t.Errorf("marshal output = %s, want %s", data, want) + } + + var decoded StreamStart + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if decoded.Artwork == nil { + t.Fatal("decoded.Artwork is nil") + } + if len(decoded.Artwork.Channels) != 1 { + t.Fatalf("channels = %d, want 1", len(decoded.Artwork.Channels)) + } + ch := decoded.Artwork.Channels[0] + if ch.Source != "album" || ch.Format != "jpeg" || ch.Width != 256 || ch.Height != 256 { + t.Errorf("decoded channel = %+v", ch) + } +} + +// TestStreamStart_PlayerOmittedWhenNil confirms that existing callers who +// only set Player (never Artwork) still produce the same wire shape. +func TestStreamStart_PlayerOnlyUnchanged(t *testing.T) { + msg := StreamStart{ + Player: &StreamStartPlayer{ + Codec: "pcm", SampleRate: 48000, Channels: 2, BitDepth: 24, + }, + } + data, err := json.Marshal(msg) + if err != nil { + t.Fatalf("marshal: %v", err) + } + // Artwork field must be omitted entirely when nil. + want := `{"player":{"codec":"pcm","sample_rate":48000,"channels":2,"bit_depth":24}}` + if string(data) != want { + t.Errorf("marshal output = %s, want %s", data, want) + } +} + +// TestMetadataState_TristateUnmarshal verifies the wire-protocol contract: +// an omitted JSON key, an explicit null, and a value must each produce a +// distinguishable receiver-side signal. The merge layer in +// pkg/sendspin.Receiver depends on this. +func TestMetadataState_TristateUnmarshal(t *testing.T) { + cases := []struct { + name string + json string + wantPtrSet bool // is Title pointer non-nil? + wantTitle string // value if non-nil + wantHasField bool // HasField("title")? + }{ + {"omitted", `{"timestamp": 1}`, false, "", false}, + {"null", `{"timestamp": 1, "title": null}`, false, "", true}, + {"value", `{"timestamp": 1, "title": "Hello"}`, true, "Hello", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var m MetadataState + if err := json.Unmarshal([]byte(tc.json), &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if (m.Title != nil) != tc.wantPtrSet { + t.Errorf("Title pointer non-nil = %v, want %v", m.Title != nil, tc.wantPtrSet) + } + if m.Title != nil && *m.Title != tc.wantTitle { + t.Errorf("Title value = %q, want %q", *m.Title, tc.wantTitle) + } + if got := m.HasField("title"); got != tc.wantHasField { + t.Errorf("HasField(\"title\") = %v, want %v", got, tc.wantHasField) + } + // Timestamp must always decode. + if m.Timestamp != 1 { + t.Errorf("Timestamp = %d, want 1", m.Timestamp) + } + // HasField("timestamp") should be true in every case + // because the wire JSON always carries it here. + if !m.HasField("timestamp") { + t.Error("HasField(\"timestamp\") = false, want true") + } + }) + } +} + +// TestMetadataState_HasFieldInGoConstruction confirms that messages built +// in process via field assignment (the conformance adapter and any +// server-side helpers do this) report HasField == true for every field. +// This is the backwards-compat guarantee callers can rely on. +func TestMetadataState_HasFieldInGoConstruction(t *testing.T) { + title := "T" + m := MetadataState{Timestamp: 42, Title: &title} + for _, key := range []string{"title", "artist", "album", "progress", "shuffle", "anything"} { + if !m.HasField(key) { + t.Errorf("HasField(%q) = false on Go-constructed value, want true", key) + } + } +} + +// TestMetadataState_TristateAcrossAllFields exercises every tristate field +// at once to make sure the alias-based decoder did not accidentally drop a +// field type (pointer-to-int, pointer-to-bool, pointer-to-struct). +func TestMetadataState_TristateAcrossAllFields(t *testing.T) { + raw := `{ + "timestamp": 100, + "title": "T", + "artist": null, + "album": "A", + "album_artist": null, + "artwork_url": "http://x", + "year": 2020, + "track": null, + "progress": {"track_progress": 1000, "track_duration": 5000, "playback_speed": 1000}, + "shuffle": true + }` + var m MetadataState + if err := json.Unmarshal([]byte(raw), &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + // Set fields decode to non-nil pointers. + if m.Title == nil || *m.Title != "T" { + t.Errorf("Title = %v, want pointer to \"T\"", m.Title) + } + if m.Album == nil || *m.Album != "A" { + t.Errorf("Album = %v, want pointer to \"A\"", m.Album) + } + if m.ArtworkURL == nil || *m.ArtworkURL != "http://x" { + t.Errorf("ArtworkURL = %v", m.ArtworkURL) + } + if m.Year == nil || *m.Year != 2020 { + t.Errorf("Year = %v, want pointer to 2020", m.Year) + } + if m.Shuffle == nil || *m.Shuffle != true { + t.Errorf("Shuffle = %v, want pointer to true", m.Shuffle) + } + if m.Progress == nil { + t.Error("Progress = nil, want non-nil") + } else if m.Progress.TrackDuration != 5000 { + t.Errorf("Progress.TrackDuration = %d, want 5000", m.Progress.TrackDuration) + } + // Null fields decode to nil pointers but HasField is true. + for _, key := range []string{"artist", "album_artist", "track"} { + if !m.HasField(key) { + t.Errorf("HasField(%q) = false, want true (null is present)", key) + } + } + if m.Artist != nil { + t.Errorf("Artist = %v, want nil (null on wire)", m.Artist) + } + if m.AlbumArtist != nil { + t.Errorf("AlbumArtist = %v, want nil (null on wire)", m.AlbumArtist) + } + if m.Track != nil { + t.Errorf("Track = %v, want nil (null on wire)", m.Track) + } + // Omitted fields: HasField false. + if m.HasField("repeat") { + t.Error("HasField(\"repeat\") = true, want false (omitted)") + } + if m.Repeat != nil { + t.Errorf("Repeat = %v, want nil (omitted)", m.Repeat) + } +} + +// TestMetadataState_RoundTripPreservesValues ensures encoding then +// decoding a populated MetadataState preserves every value. The wire +// shape must be unchanged by the new decoder. +func TestMetadataState_RoundTripPreservesValues(t *testing.T) { + title := "Song" + artist := "Artist" + album := "Album" + original := MetadataState{ + Timestamp: 12345, + Title: &title, + Artist: &artist, + Album: &album, + Progress: &ProgressState{ + TrackProgress: 1000, TrackDuration: 60000, PlaybackSpeed: 1000, + }, + } + data, err := json.Marshal(&original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var decoded MetadataState + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if decoded.Timestamp != original.Timestamp { + t.Errorf("Timestamp = %d, want %d", decoded.Timestamp, original.Timestamp) + } + if decoded.Title == nil || *decoded.Title != title { + t.Errorf("Title = %v", decoded.Title) + } + if decoded.Progress == nil || decoded.Progress.TrackDuration != 60000 { + t.Errorf("Progress = %+v", decoded.Progress) + } + // Round-trip preserves presence: the original was constructed in Go + // (presentKeys nil → HasField always true), then re-marshaled (omitempty + // drops nil pointers), then re-decoded (presentKeys captures only the + // fields that survived omitempty). HasField must report present for the + // fields we set. + if !decoded.HasField("title") || !decoded.HasField("progress") { + t.Error("decoded.HasField missed a set field after round-trip") + } +} diff --git a/third_party/sendspin-go/pkg/protocol/server_conn.go b/third_party/sendspin-go/pkg/protocol/server_conn.go new file mode 100644 index 0000000..5d1c895 --- /dev/null +++ b/third_party/sendspin-go/pkg/protocol/server_conn.go @@ -0,0 +1,145 @@ +// ABOUTME: ServerConn wraps a WebSocket for server-side typed message sending +// ABOUTME: CGO-free alternative to sendspin.ServerClient for adapters and tools +package protocol + +import ( + "encoding/binary" + "encoding/json" + "fmt" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +// ServerConn wraps an accepted WebSocket connection with typed Send and +// SendBinary methods plus a background writer goroutine. It is the +// lightweight, CGO-free counterpart to sendspin.ServerClient — use it +// when you need typed protocol I/O without the full server runtime +// (e.g., conformance adapters, CLI tools, test harnesses). +// +// The zero value is not usable — construct via NewServerConn. +type ServerConn struct { + conn *websocket.Conn + id string + name string + sendChan chan interface{} + done chan struct{} + once sync.Once +} + +// NewServerConn wraps an existing WebSocket connection and starts a +// background writer goroutine. Call Close() when done to stop the +// writer. Does NOT close the underlying WebSocket — the caller owns +// that lifecycle. +func NewServerConn(conn *websocket.Conn, id, name string) *ServerConn { + sc := &ServerConn{ + conn: conn, + id: id, + name: name, + sendChan: make(chan interface{}, 100), + done: make(chan struct{}), + } + go sc.runWriter() + return sc +} + +// ID returns the identifier passed to NewServerConn. +func (sc *ServerConn) ID() string { return sc.id } + +// Name returns the name passed to NewServerConn. +func (sc *ServerConn) Name() string { return sc.name } + +// Send enqueues a typed control message (JSON envelope) for +// transmission. Returns an error immediately if the send buffer is +// full. The message is wrapped in a {"type": msgType, "payload": ...} +// envelope by the writer goroutine. +func (sc *ServerConn) Send(msgType string, payload interface{}) error { + msg := Message{ + Type: msgType, + Payload: payload, + } + select { + case sc.sendChan <- msg: + return nil + default: + return fmt.Errorf("send buffer full") + } +} + +// SendBinary enqueues a raw binary frame for transmission. Returns an +// error immediately if the send buffer is full. +func (sc *ServerConn) SendBinary(data []byte) error { + select { + case sc.sendChan <- data: + return nil + default: + return fmt.Errorf("send buffer full") + } +} + +// Close stops the writer goroutine. Safe to call multiple times. Does +// NOT close the underlying WebSocket connection. +func (sc *ServerConn) Close() { + sc.once.Do(func() { + close(sc.done) + }) +} + +func (sc *ServerConn) runWriter() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + const writeDeadline = 10 * time.Second + + for { + select { + case msg := <-sc.sendChan: + switch v := msg.(type) { + case []byte: + sc.conn.SetWriteDeadline(time.Now().Add(writeDeadline)) + if err := sc.conn.WriteMessage(websocket.BinaryMessage, v); err != nil { + return + } + default: + data, err := json.Marshal(v) + if err != nil { + continue + } + sc.conn.SetWriteDeadline(time.Now().Add(writeDeadline)) + if err := sc.conn.WriteMessage(websocket.TextMessage, data); err != nil { + return + } + } + + case <-ticker.C: + if err := sc.conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(10*time.Second)); err != nil { + return + } + + case <-sc.done: + return + } + } +} + +// CreateAudioChunk packs timestamp + payload into a Sendspin binary audio +// frame: [1 byte message type][8 byte big-endian timestamp (µs)][audio bytes]. +func CreateAudioChunk(timestamp int64, audioData []byte) []byte { + chunk := make([]byte, BinaryMessageHeaderSize+len(audioData)) + chunk[0] = AudioChunkMessageType + binary.BigEndian.PutUint64(chunk[1:BinaryMessageHeaderSize], uint64(timestamp)) + copy(chunk[BinaryMessageHeaderSize:], audioData) + return chunk +} + +// CreateArtworkChunk packs an artwork frame: [1 byte message type][8 byte +// timestamp (µs)][image bytes]. Channel is 0-3, mapping to the artwork +// channel message types (8-11). +func CreateArtworkChunk(channel int, timestamp int64, imageData []byte) []byte { + chunk := make([]byte, BinaryMessageHeaderSize+len(imageData)) + chunk[0] = byte(ArtworkChannel0MessageType + channel) + binary.BigEndian.PutUint64(chunk[1:BinaryMessageHeaderSize], uint64(timestamp)) + copy(chunk[BinaryMessageHeaderSize:], imageData) + return chunk +} diff --git a/third_party/sendspin-go/pkg/protocol/server_conn_test.go b/third_party/sendspin-go/pkg/protocol/server_conn_test.go new file mode 100644 index 0000000..7274bf7 --- /dev/null +++ b/third_party/sendspin-go/pkg/protocol/server_conn_test.go @@ -0,0 +1,146 @@ +// ABOUTME: Tests for ServerConn typed connection wrapper +// ABOUTME: Verifies Send/SendBinary/Close lifecycle and frame helpers +package protocol + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gorilla/websocket" +) + +func TestServerConn_SendAndClose(t *testing.T) { + received := make(chan Message, 1) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upgrader := websocket.Upgrader{} + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + + _, data, err := conn.ReadMessage() + if err != nil { + t.Errorf("server read: %v", err) + return + } + var msg Message + if err := json.Unmarshal(data, &msg); err != nil { + t.Errorf("unmarshal: %v", err) + return + } + received <- msg + })) + defer server.Close() + + wsURL := "ws" + server.URL[len("http"):] + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + + sc := NewServerConn(conn, "test-id", "Test Server") + + if err := sc.Send("server/hello", map[string]string{"name": "test"}); err != nil { + t.Fatalf("Send: %v", err) + } + + select { + case msg := <-received: + if msg.Type != "server/hello" { + t.Errorf("type = %q, want server/hello", msg.Type) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for message") + } + + sc.Close() + sc.Close() // double-close must not panic +} + +func TestServerConn_SendBinary(t *testing.T) { + received := make(chan []byte, 1) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upgrader := websocket.Upgrader{} + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + + msgType, data, err := conn.ReadMessage() + if err != nil { + t.Errorf("server read: %v", err) + return + } + if msgType != websocket.BinaryMessage { + t.Errorf("message type = %d, want binary", msgType) + } + received <- data + })) + defer server.Close() + + wsURL := "ws" + server.URL[len("http"):] + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + + sc := NewServerConn(conn, "test-id", "Test") + defer sc.Close() + + chunk := CreateAudioChunk(1000000, []byte{0xAA, 0xBB}) + if err := sc.SendBinary(chunk); err != nil { + t.Fatalf("SendBinary: %v", err) + } + + select { + case data := <-received: + if data[0] != AudioChunkMessageType { + t.Errorf("type byte = %d, want %d", data[0], AudioChunkMessageType) + } + if len(data) != BinaryMessageHeaderSize+2 { + t.Errorf("len = %d, want %d", len(data), BinaryMessageHeaderSize+2) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for binary message") + } +} + +func TestServerConn_Accessors(t *testing.T) { + sc := &ServerConn{id: "abc", name: "Test"} + if sc.ID() != "abc" { + t.Errorf("ID() = %q, want abc", sc.ID()) + } + if sc.Name() != "Test" { + t.Errorf("Name() = %q, want Test", sc.Name()) + } +} + +func TestCreateAudioChunk_Format(t *testing.T) { + chunk := CreateAudioChunk(1000000, []byte{0xAA, 0xBB}) + if chunk[0] != AudioChunkMessageType { + t.Errorf("type = %d, want %d", chunk[0], AudioChunkMessageType) + } + if len(chunk) != BinaryMessageHeaderSize+2 { + t.Errorf("len = %d, want %d", len(chunk), BinaryMessageHeaderSize+2) + } +} + +func TestCreateArtworkChunk_Format(t *testing.T) { + chunk := CreateArtworkChunk(2, 5000000, []byte{0xFF}) + expectedType := byte(ArtworkChannel0MessageType + 2) + if chunk[0] != expectedType { + t.Errorf("type = %d, want %d", chunk[0], expectedType) + } + if len(chunk) != BinaryMessageHeaderSize+1 { + t.Errorf("len = %d, want %d", len(chunk), BinaryMessageHeaderSize+1) + } +} diff --git a/third_party/sendspin-go/pkg/sendspin/buffer_tracker.go b/third_party/sendspin-go/pkg/sendspin/buffer_tracker.go new file mode 100644 index 0000000..e9d09ba --- /dev/null +++ b/third_party/sendspin-go/pkg/sendspin/buffer_tracker.go @@ -0,0 +1,115 @@ +// ABOUTME: Per-client buffer tracker for send-ahead pacing +// ABOUTME: Tracks in-flight bytes and duration to prevent client buffer overflow +package sendspin + +const ( + // DefaultMaxBufferDurationUs caps buffer duration at 30 seconds + // regardless of byte capacity, matching aiosendspin's behavior. + DefaultMaxBufferDurationUs = 30_000_000 +) + +// bufferedChunk records one sent chunk's end time, size, and duration. +type bufferedChunk struct { + endTimeUs int64 + byteCount int + durationUs int64 +} + +// BufferTracker tracks the estimated bytes and duration of audio that a +// client has buffered (sent but not yet played). The server checks +// CanSend before each chunk send and skips the client if the buffer is +// full. PruneConsumed removes chunks whose playback time has passed. +// +// All methods are called from the same goroutine (the audio tick loop) +// so no synchronization is needed. +type BufferTracker struct { + ring []bufferedChunk + head int // index of the oldest entry + count int // number of valid entries + + bufferedBytes int + bufferedDurationUs int64 + + capacityBytes int + maxDurationUs int64 +} + +// NewBufferTracker creates a tracker with the given byte capacity and +// a 30-second duration cap. +func NewBufferTracker(capacityBytes int) *BufferTracker { + // Pre-allocate for ~30s of 20ms chunks = 1500 slots. + // The ring grows if needed but this avoids early resizes. + initialCap := 1500 + if capacityBytes <= 0 { + capacityBytes = 1048576 // 1MB default + } + return &BufferTracker{ + ring: make([]bufferedChunk, initialCap), + capacityBytes: capacityBytes, + maxDurationUs: DefaultMaxBufferDurationUs, + } +} + +// PruneConsumed removes all chunks whose end time has passed. Call this +// once per tick before CanSend. +func (t *BufferTracker) PruneConsumed(nowUs int64) { + for t.count > 0 { + entry := t.ring[t.head] + if entry.endTimeUs > nowUs { + break + } + t.bufferedBytes -= entry.byteCount + t.bufferedDurationUs -= entry.durationUs + t.head = (t.head + 1) % len(t.ring) + t.count-- + } +} + +// CanSend reports whether a chunk of the given size and duration can be +// sent without exceeding the client's buffer capacity or the duration cap. +func (t *BufferTracker) CanSend(byteCount int, durationUs int64) bool { + if t.bufferedBytes+byteCount > t.capacityBytes { + return false + } + if t.bufferedDurationUs+durationUs > t.maxDurationUs { + return false + } + return true +} + +// Register records a sent chunk. Call this after CanSend returns true +// and the chunk has been enqueued for transmission. +func (t *BufferTracker) Register(endTimeUs int64, byteCount int, durationUs int64) { + if t.count == len(t.ring) { + t.grow() + } + idx := (t.head + t.count) % len(t.ring) + t.ring[idx] = bufferedChunk{ + endTimeUs: endTimeUs, + byteCount: byteCount, + durationUs: durationUs, + } + t.count++ + t.bufferedBytes += byteCount + t.bufferedDurationUs += durationUs +} + +// BufferedBytes returns the current estimated bytes in the client's buffer. +func (t *BufferTracker) BufferedBytes() int { + return t.bufferedBytes +} + +// BufferedDurationUs returns the current estimated duration in the client's buffer. +func (t *BufferTracker) BufferedDurationUs() int64 { + return t.bufferedDurationUs +} + +func (t *BufferTracker) grow() { + newCap := len(t.ring) * 2 + newRing := make([]bufferedChunk, newCap) + for i := 0; i < t.count; i++ { + newRing[i] = t.ring[(t.head+i)%len(t.ring)] + } + t.ring = newRing + t.head = 0 +} diff --git a/third_party/sendspin-go/pkg/sendspin/buffer_tracker_test.go b/third_party/sendspin-go/pkg/sendspin/buffer_tracker_test.go new file mode 100644 index 0000000..7bd2654 --- /dev/null +++ b/third_party/sendspin-go/pkg/sendspin/buffer_tracker_test.go @@ -0,0 +1,157 @@ +// ABOUTME: Tests for the per-client BufferTracker +// ABOUTME: Verifies capacity gating, duration cap, prune, and ring growth +package sendspin + +import ( + "testing" +) + +func TestBufferTracker_BasicFlow(t *testing.T) { + tracker := NewBufferTracker(1000) // 1000 bytes capacity + + // Should be able to send when empty + if !tracker.CanSend(100, 20000) { + t.Error("should be able to send when empty") + } + + // Register a chunk + tracker.Register(100000, 100, 20000) // ends at 100ms, 100 bytes, 20ms duration + + if tracker.BufferedBytes() != 100 { + t.Errorf("buffered bytes = %d, want 100", tracker.BufferedBytes()) + } + + // Should still be able to send more + if !tracker.CanSend(100, 20000) { + t.Error("should be able to send with capacity remaining") + } +} + +func TestBufferTracker_ByteCapacity(t *testing.T) { + tracker := NewBufferTracker(200) + + // Fill to capacity + tracker.Register(100000, 100, 20000) + tracker.Register(200000, 100, 20000) + + // Should NOT be able to send — at capacity + if tracker.CanSend(100, 20000) { + t.Error("should not be able to send at byte capacity") + } + + // Even a 1-byte chunk should be rejected + if tracker.CanSend(1, 20000) { + t.Error("should not be able to send when over byte capacity") + } + + // Prune the first chunk (simulate time passing) + tracker.PruneConsumed(100000) // now = 100ms, first chunk ends at 100ms + + // Should be able to send again + if !tracker.CanSend(100, 20000) { + t.Error("should be able to send after pruning") + } + if tracker.BufferedBytes() != 100 { + t.Errorf("buffered bytes after prune = %d, want 100", tracker.BufferedBytes()) + } +} + +func TestBufferTracker_DurationCap(t *testing.T) { + tracker := NewBufferTracker(1000000) // large byte capacity + + // Fill 30 seconds of 20ms chunks (1500 chunks × 20000μs = 30s) + endTime := int64(0) + for i := 0; i < 1500; i++ { + endTime += 20000 + tracker.Register(endTime, 100, 20000) + } + + // At exactly 30s — should NOT be able to send more + if tracker.CanSend(100, 20000) { + t.Error("should not be able to send at 30s duration cap") + } + + // Prune 1 chunk — should free up space + tracker.PruneConsumed(20000) + if !tracker.CanSend(100, 20000) { + t.Error("should be able to send after pruning one chunk") + } +} + +func TestBufferTracker_PruneMultiple(t *testing.T) { + tracker := NewBufferTracker(10000) + + tracker.Register(100000, 50, 20000) + tracker.Register(200000, 60, 20000) + tracker.Register(300000, 70, 20000) + + if tracker.BufferedBytes() != 180 { + t.Errorf("bytes = %d, want 180", tracker.BufferedBytes()) + } + + // Prune first two + tracker.PruneConsumed(200000) + if tracker.BufferedBytes() != 70 { + t.Errorf("after prune bytes = %d, want 70", tracker.BufferedBytes()) + } + if tracker.BufferedDurationUs() != 20000 { + t.Errorf("after prune duration = %d, want 20000", tracker.BufferedDurationUs()) + } +} + +func TestBufferTracker_PruneAll(t *testing.T) { + tracker := NewBufferTracker(10000) + + tracker.Register(100000, 50, 20000) + tracker.Register(200000, 60, 20000) + + tracker.PruneConsumed(300000) // past both + + if tracker.BufferedBytes() != 0 { + t.Errorf("bytes after full prune = %d, want 0", tracker.BufferedBytes()) + } + if tracker.BufferedDurationUs() != 0 { + t.Errorf("duration after full prune = %d, want 0", tracker.BufferedDurationUs()) + } +} + +func TestBufferTracker_RingGrowth(t *testing.T) { + tracker := NewBufferTracker(1000000) + + // Fill beyond the initial ring capacity (1500) + for i := 0; i < 2000; i++ { + endTime := int64((i + 1) * 20000) + tracker.Register(endTime, 10, 20000) + } + + // Prune half + tracker.PruneConsumed(1000 * 20000) // prune first 1000 + + if tracker.BufferedBytes() != 10000 { + t.Errorf("bytes after partial prune = %d, want 10000", tracker.BufferedBytes()) + } + + // Should still work correctly after growth + if !tracker.CanSend(10, 20000) { + t.Error("should be able to send after partial prune") + } +} + +func TestBufferTracker_EmptyPrune(t *testing.T) { + tracker := NewBufferTracker(1000) + + // Prune on empty tracker should not panic + tracker.PruneConsumed(999999) + + if tracker.BufferedBytes() != 0 { + t.Error("empty tracker should have 0 bytes") + } +} + +func TestBufferTracker_ZeroCapacity(t *testing.T) { + tracker := NewBufferTracker(0) // should default to 1MB + + if !tracker.CanSend(1000, 20000) { + t.Error("zero capacity should default to 1MB") + } +} diff --git a/third_party/sendspin-go/pkg/sendspin/client_dialer.go b/third_party/sendspin-go/pkg/sendspin/client_dialer.go new file mode 100644 index 0000000..4df9664 --- /dev/null +++ b/third_party/sendspin-go/pkg/sendspin/client_dialer.go @@ -0,0 +1,158 @@ +// ABOUTME: Outbound client discovery dialer for server-initiated mode +// ABOUTME: Converts discovery.ClientInfo into dialed WebSocket connections + +package sendspin + +import ( + "context" + "fmt" + "log" + "strings" + "sync" + "time" + + "github.com/Sendspin/sendspin-go/internal/discovery" + "github.com/gorilla/websocket" +) + +// clientInfoURL builds a ws:// URL from a discovered ClientInfo. +// Normalizes Path to ensure a single leading slash. +func clientInfoURL(info *discovery.ClientInfo) string { + path := info.Path + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + return fmt.Sprintf("ws://%s:%d%s", info.Host, info.Port, path) +} + +// dialAndHandle opens a WebSocket to a discovered client and hands the +// connection to the provided handler. The handler is expected to block +// until the connection is fully drained (e.g. handleConnection). The +// handler's return value is propagated so the dialer can distinguish +// "session ran to completion — latch the instance" from "rejected by +// filter — back off and retry." +func dialAndHandle(ctx context.Context, info *discovery.ClientInfo, handle func(*websocket.Conn) error) error { + url := clientInfoURL(info) + dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second} + + conn, _, err := dialer.DialContext(ctx, url, nil) + if err != nil { + return fmt.Errorf("dial %s: %w", url, err) + } + log.Printf("Dialed discovered client %s at %s", info.Name, url) + return handle(conn) +} + +// dialFunc dials a discovered client and returns when the resulting +// connection has been fully handled. Returning nil means the connection +// completed normally; returning an error means the dial or handshake +// failed and the slot should be released for retry. +type dialFunc func(ctx context.Context, info *discovery.ClientInfo) error + +// clientDialer consumes discovery events and dispatches one goroutine +// per unique mDNS instance, deduping so we never open two sockets to +// the same advertisement concurrently. +type clientDialer struct { + in <-chan *discovery.ClientInfo + dial dialFunc + + baseBackoff time.Duration + maxBackoff time.Duration + + mu sync.Mutex + active map[string]bool // currently dialing/connected + cooldown map[string]time.Time // instance -> earliest retry time + failures map[string]int // consecutive failure count per instance +} + +// newClientDialer constructs a clientDialer that reads discovery events +// from in and dispatches them through dial. +func newClientDialer(in <-chan *discovery.ClientInfo, dial dialFunc) *clientDialer { + return &clientDialer{ + in: in, + dial: dial, + baseBackoff: 1 * time.Second, + maxBackoff: 30 * time.Second, + active: make(map[string]bool), + cooldown: make(map[string]time.Time), + failures: make(map[string]int), + } +} + +// run pumps discovery events until ctx is cancelled. It returns after +// all in-flight dial goroutines have completed. +func (d *clientDialer) run(ctx context.Context) { + var wg sync.WaitGroup + defer wg.Wait() + + for { + select { + case <-ctx.Done(): + return + case info, ok := <-d.in: + if !ok { + return + } + if info == nil { + continue + } + if !d.claim(info.Instance) { + continue + } + wg.Add(1) + go func(info *discovery.ClientInfo) { + defer wg.Done() + err := d.dial(ctx, info) + d.release(info.Instance, err) + if err != nil { + log.Printf("dial client %s: %v", info.Instance, err) + } + }(info) + } + } +} + +// claim returns true if the instance slot was free, not in cooldown, +// and is now owned by the caller. Returns false otherwise. +func (d *clientDialer) claim(instance string) bool { + d.mu.Lock() + defer d.mu.Unlock() + if d.active[instance] { + return false + } + if until, ok := d.cooldown[instance]; ok && time.Now().Before(until) { + return false + } + d.active[instance] = true + return true +} + +// release updates the instance slot after a dial attempt completes. +// On success (dialErr == nil) the instance stays latched in the active +// set — we never re-dial a successfully-connected instance. On error +// it frees the slot and schedules an exponentially-backed-off cooldown +// before the next retry. +func (d *clientDialer) release(instance string, dialErr error) { + d.mu.Lock() + defer d.mu.Unlock() + + if dialErr == nil { + // Latch: keep active[instance] = true so future discovery + // events for this instance are silently ignored. The server + // already handled the connection; re-dialing would create a + // duplicate session. + delete(d.failures, instance) + delete(d.cooldown, instance) + return + } + + // Dial failed — release the slot so the instance can be retried + // after the backoff period. + delete(d.active, instance) + d.failures[instance]++ + backoff := d.baseBackoff * time.Duration(1<<(d.failures[instance]-1)) + if backoff > d.maxBackoff || backoff <= 0 { + backoff = d.maxBackoff + } + d.cooldown[instance] = time.Now().Add(backoff) +} diff --git a/third_party/sendspin-go/pkg/sendspin/client_dialer_test.go b/third_party/sendspin-go/pkg/sendspin/client_dialer_test.go new file mode 100644 index 0000000..7bbc641 --- /dev/null +++ b/third_party/sendspin-go/pkg/sendspin/client_dialer_test.go @@ -0,0 +1,165 @@ +// ABOUTME: Tests for outbound client discovery and dialing +// ABOUTME: Uses injected dial functions — no real network I/O + +package sendspin + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Sendspin/sendspin-go/internal/discovery" +) + +func TestClientInfoURL(t *testing.T) { + tests := []struct { + name string + info *discovery.ClientInfo + want string + }{ + { + name: "ipv4 host", + info: &discovery.ClientInfo{Host: "192.168.1.42", Port: 8928, Path: "/sendspin"}, + want: "ws://192.168.1.42:8928/sendspin", + }, + { + name: "path missing leading slash is normalized", + info: &discovery.ClientInfo{Host: "192.168.1.42", Port: 8928, Path: "sendspin"}, + want: "ws://192.168.1.42:8928/sendspin", + }, + { + name: "custom path preserved", + info: &discovery.ClientInfo{Host: "10.0.0.5", Port: 9000, Path: "/custom/endpoint"}, + want: "ws://10.0.0.5:9000/custom/endpoint", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := clientInfoURL(tt.info) + if got != tt.want { + t.Errorf("clientInfoURL = %q, want %q", got, tt.want) + } + }) + } +} + +func TestClientDialerDedupesByInstance(t *testing.T) { + in := make(chan *discovery.ClientInfo, 10) + + var dialCalls int32 + var mu sync.Mutex + var seen []string + + dial := func(ctx context.Context, info *discovery.ClientInfo) error { + atomic.AddInt32(&dialCalls, 1) + mu.Lock() + seen = append(seen, info.Instance) + mu.Unlock() + return nil + } + + d := newClientDialer(in, dial) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan struct{}) + go func() { + d.run(ctx) + close(done) + }() + + // Emit the same instance 3 times and a different instance once + in <- &discovery.ClientInfo{Instance: "a._sendspin._tcp.local.", Host: "1.1.1.1", Port: 8928, Path: "/sendspin"} + in <- &discovery.ClientInfo{Instance: "a._sendspin._tcp.local.", Host: "1.1.1.1", Port: 8928, Path: "/sendspin"} + in <- &discovery.ClientInfo{Instance: "b._sendspin._tcp.local.", Host: "1.1.1.2", Port: 8928, Path: "/sendspin"} + in <- &discovery.ClientInfo{Instance: "a._sendspin._tcp.local.", Host: "1.1.1.1", Port: 8928, Path: "/sendspin"} + + // Wait until at least 2 unique dials have happened + deadline := time.After(500 * time.Millisecond) + for { + if atomic.LoadInt32(&dialCalls) >= 2 { + break + } + select { + case <-deadline: + t.Fatalf("timed out waiting for dial calls, got %d", atomic.LoadInt32(&dialCalls)) + case <-time.After(10 * time.Millisecond): + } + } + + // Give any extra (incorrect) calls a chance to fire + time.Sleep(50 * time.Millisecond) + + cancel() + <-done + + if got := atomic.LoadInt32(&dialCalls); got != 2 { + t.Errorf("dialCalls = %d, want 2 (one per unique instance)", got) + } + mu.Lock() + defer mu.Unlock() + if len(seen) != 2 { + t.Fatalf("seen = %v, want 2 entries", seen) + } +} + +func waitForCalls(t *testing.T, counter *int32, want int32, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if atomic.LoadInt32(counter) >= want { + return + } + time.Sleep(2 * time.Millisecond) + } + t.Fatalf("timed out waiting for %d calls, got %d", want, atomic.LoadInt32(counter)) +} + +func TestClientDialerBackoffOnFailure(t *testing.T) { + in := make(chan *discovery.ClientInfo, 10) + + var dialCalls int32 + dial := func(ctx context.Context, info *discovery.ClientInfo) error { + atomic.AddInt32(&dialCalls, 1) + return errors.New("boom") + } + + d := newClientDialer(in, dial) + // Override backoff clock to keep the test fast + d.baseBackoff = 20 * time.Millisecond + d.maxBackoff = 80 * time.Millisecond + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan struct{}) + go func() { + d.run(ctx) + close(done) + }() + + info := &discovery.ClientInfo{Instance: "a._sendspin._tcp.local.", Host: "1.1.1.1", Port: 8928, Path: "/sendspin"} + + // First event — should dial immediately + in <- info + waitForCalls(t, &dialCalls, 1, 200*time.Millisecond) + + // Immediately re-emit — should be rejected by backoff + in <- info + time.Sleep(5 * time.Millisecond) + if got := atomic.LoadInt32(&dialCalls); got != 1 { + t.Errorf("dialCalls after immediate retry = %d, want 1 (blocked by backoff)", got) + } + + // Wait past base backoff, re-emit — should dial again + time.Sleep(30 * time.Millisecond) + in <- info + waitForCalls(t, &dialCalls, 2, 200*time.Millisecond) + + cancel() + <-done +} diff --git a/third_party/sendspin-go/pkg/sendspin/client_discovery_integration_test.go b/third_party/sendspin-go/pkg/sendspin/client_discovery_integration_test.go new file mode 100644 index 0000000..1f34cc5 --- /dev/null +++ b/third_party/sendspin-go/pkg/sendspin/client_discovery_integration_test.go @@ -0,0 +1,139 @@ +// ABOUTME: Integration test for server-initiated client discovery +// ABOUTME: Fakes an mDNS advertisement and verifies the server dials + handshakes + +package sendspin + +import ( + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "os" + "strconv" + "strings" + "testing" + "time" + + "github.com/Sendspin/sendspin-go/pkg/protocol" + "github.com/gorilla/websocket" + "github.com/hashicorp/mdns" +) + +func TestServerDiscoversAndDialsClient(t *testing.T) { + // mDNS multicast is unreliable in some environments (CI sandboxes, + // Windows boxes with strict firewalls or Hyper-V virtual switches). + // Opt in with SENDSPIN_MULTICAST_TESTS=1 when running locally. + if os.Getenv("SENDSPIN_MULTICAST_TESTS") == "" { + t.Skip("requires multicast; set SENDSPIN_MULTICAST_TESTS=1 to enable") + } + + // 1. Stand up a fake "player" HTTP server that upgrades to WS and + // sends client/hello. + upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} + playerReady := make(chan string, 1) // receives serverID via server/hello + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Errorf("upgrade: %v", err) + return + } + defer conn.Close() + + hello := protocol.Message{ + Type: "client/hello", + Payload: protocol.ClientHello{ + ClientID: "test-player-1", + Name: "Test Player", + SupportedRoles: []string{"player@v1"}, + }, + } + data, _ := json.Marshal(hello) + if err := conn.WriteMessage(websocket.TextMessage, data); err != nil { + t.Errorf("write hello: %v", err) + return + } + + _, resp, err := conn.ReadMessage() + if err != nil { + return + } + var msg protocol.Message + if err := json.Unmarshal(resp, &msg); err != nil { + return + } + if msg.Type == "server/hello" { + playerReady <- "ok" + } + + // Block until the server closes us. + for { + if _, _, err := conn.ReadMessage(); err != nil { + return + } + } + })) + defer ts.Close() + + _, portStr, err := net.SplitHostPort(strings.TrimPrefix(ts.URL, "http://")) + if err != nil { + t.Fatalf("split host: %v", err) + } + port, _ := strconv.Atoi(portStr) + + // 2. Advertise an mDNS _sendspin._tcp record pointing at httptest. + ips := []net.IP{net.ParseIP("127.0.0.1")} + svc, err := mdns.NewMDNSService( + "test-player-instance", + "_sendspin._tcp", + "", + "", + port, + ips, + []string{"path=/", "name=Test Player"}, + ) + if err != nil { + t.Fatalf("mdns service: %v", err) + } + mdnsServer, err := mdns.NewServer(&mdns.Config{Zone: svc}) + if err != nil { + t.Fatalf("mdns server: %v", err) + } + defer mdnsServer.Shutdown() + + // 3. Start a Sendspin server with DiscoverClients=true. + source := NewTestTone(48000, 2) + srv, err := NewServer(ServerConfig{ + Port: findFreePort(t), + Name: "Test Server", + Source: source, + EnableMDNS: false, + DiscoverClients: true, + }) + if err != nil { + t.Fatalf("NewServer: %v", err) + } + + serverDone := make(chan error, 1) + go func() { serverDone <- srv.Start() }() + defer srv.Stop() + + // 4. Wait for the player to confirm it received server/hello. + select { + case <-playerReady: + // success + case <-time.After(10 * time.Second): + t.Fatal("timed out waiting for dialed handshake") + } +} + +// findFreePort returns a TCP port that is free at call time. +func findFreePort(t *testing.T) int { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer l.Close() + return l.Addr().(*net.TCPAddr).Port +} diff --git a/third_party/sendspin-go/pkg/sendspin/client_id.go b/third_party/sendspin-go/pkg/sendspin/client_id.go new file mode 100644 index 0000000..e6cd37c --- /dev/null +++ b/third_party/sendspin-go/pkg/sendspin/client_id.go @@ -0,0 +1,130 @@ +// ABOUTME: Stable client_id resolution (override -> config value -> MAC -> generated UUID) +// ABOUTME: Persistence of new values is delegated to a caller-supplied callback +package sendspin + +import ( + "bytes" + "log" + "net" + "sort" + + "github.com/google/uuid" +) + +type clientIDSource string + +const ( + sourceOverride clientIDSource = "override" + sourceConfig clientIDSource = "config" + sourceMAC clientIDSource = "mac" + sourceGenerated clientIDSource = "generated" +) + +// PersistFn is called by ResolveClientID whenever a new client_id needs to be +// written back to the config file. Implementations typically call +// WriteStringKey(path, "client_id", id). Returning an error is logged but not +// fatal — the player will still boot with the resolved id; the id just won't +// survive a restart. +type PersistFn func(id string) error + +// ResolveClientID returns a stable client_id, following this precedence: +// 1. override — used as-is and persisted via persist (so a one-time seed sticks) +// 2. fromConfig — used as-is, no persistence (value came from the file we'd write to) +// 3. primary network interface MAC (xx:xx:xx:xx:xx:xx) — not persisted; MAC is inherently stable +// 4. freshly generated UUID — persisted so the next launch takes path 2 +// +// Persisted value beating MAC is deliberate: once Music Assistant has +// registered a player under one id, switching to another creates a duplicate +// entry. Deleting client_id from the config file is the explicit "re-derive". +// +// persist may be nil if the caller cannot write back (e.g. no config path +// resolvable). In that case generated/override ids still work for the session +// but won't survive a restart. +func ResolveClientID(override, fromConfig string, persist PersistFn) (string, error) { + if override != "" { + if override != fromConfig { + tryPersist(persist, override, "override") + } + logClientID(override, sourceOverride, persist != nil && override != fromConfig) + return override, nil + } + + if fromConfig != "" { + logClientID(fromConfig, sourceConfig, false) + return fromConfig, nil + } + + if mac, ok := pickStableMAC(allInterfaces()); ok { + logClientID(mac, sourceMAC, false) + return mac, nil + } + + generated := uuid.New().String() + tryPersist(persist, generated, "generated id") + logClientID(generated, sourceGenerated, persist != nil) + return generated, nil +} + +func tryPersist(persist PersistFn, id, what string) { + if persist == nil { + return + } + if err := persist(id); err != nil { + log.Printf("client_id: could not persist %s: %v", what, err) + } +} + +func logClientID(id string, source clientIDSource, persisted bool) { + if persisted { + log.Printf("client_id: %s (source: %s, persisted)", id, source) + return + } + log.Printf("client_id: %s (source: %s)", id, source) +} + +// allInterfaces is a package-level var so tests can stub it. +var allInterfaces = func() []net.Interface { + ifaces, err := net.Interfaces() + if err != nil { + return nil + } + return ifaces +} + +// pickStableMAC returns the MAC of the alphabetically-first eligible interface. +// Eligible means: up, not loopback, 6-byte hardware address, not zero, not broadcast. +// Alphabetical ordering gives deterministic selection across reboots and OSes +// (eth0 < wlan0, Ethernet < Wi-Fi). +func pickStableMAC(ifaces []net.Interface) (string, bool) { + eligible := make([]net.Interface, 0, len(ifaces)) + for _, iface := range ifaces { + if iface.Flags&net.FlagUp == 0 { + continue + } + if iface.Flags&net.FlagLoopback != 0 { + continue + } + if len(iface.HardwareAddr) != 6 { + continue + } + if isZeroMAC(iface.HardwareAddr) || isBroadcastMAC(iface.HardwareAddr) { + continue + } + eligible = append(eligible, iface) + } + if len(eligible) == 0 { + return "", false + } + sort.Slice(eligible, func(i, j int) bool { + return eligible[i].Name < eligible[j].Name + }) + return eligible[0].HardwareAddr.String(), true +} + +func isZeroMAC(mac net.HardwareAddr) bool { + return bytes.Equal(mac, []byte{0, 0, 0, 0, 0, 0}) +} + +func isBroadcastMAC(mac net.HardwareAddr) bool { + return bytes.Equal(mac, []byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff}) +} diff --git a/third_party/sendspin-go/pkg/sendspin/client_id_test.go b/third_party/sendspin-go/pkg/sendspin/client_id_test.go new file mode 100644 index 0000000..acef479 --- /dev/null +++ b/third_party/sendspin-go/pkg/sendspin/client_id_test.go @@ -0,0 +1,244 @@ +// ABOUTME: Tests for ResolveClientID precedence and the MAC picker filter +package sendspin + +import ( + "errors" + "net" + "testing" +) + +func mac(b ...byte) net.HardwareAddr { return net.HardwareAddr(b) } + +func TestPickStableMAC(t *testing.T) { + up := net.FlagUp + lo := net.FlagUp | net.FlagLoopback + down := net.Flags(0) + + tests := []struct { + name string + ifaces []net.Interface + want string + wantOk bool + }{ + { + name: "single eligible", + ifaces: []net.Interface{ + {Name: "eth0", Flags: up, HardwareAddr: mac(0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff)}, + }, + want: "aa:bb:cc:dd:ee:ff", + wantOk: true, + }, + { + name: "skips loopback", + ifaces: []net.Interface{ + {Name: "lo", Flags: lo, HardwareAddr: mac(1, 2, 3, 4, 5, 6)}, + {Name: "eth0", Flags: up, HardwareAddr: mac(0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff)}, + }, + want: "aa:bb:cc:dd:ee:ff", + wantOk: true, + }, + { + name: "skips interfaces that are not up", + ifaces: []net.Interface{ + {Name: "eth0", Flags: down, HardwareAddr: mac(1, 1, 1, 1, 1, 1)}, + {Name: "wlan0", Flags: up, HardwareAddr: mac(2, 2, 2, 2, 2, 2)}, + }, + want: "02:02:02:02:02:02", + wantOk: true, + }, + { + name: "skips zero MAC", + ifaces: []net.Interface{ + {Name: "eth0", Flags: up, HardwareAddr: mac(0, 0, 0, 0, 0, 0)}, + {Name: "wlan0", Flags: up, HardwareAddr: mac(0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff)}, + }, + want: "aa:bb:cc:dd:ee:ff", + wantOk: true, + }, + { + name: "skips broadcast MAC", + ifaces: []net.Interface{ + {Name: "eth0", Flags: up, HardwareAddr: mac(0xff, 0xff, 0xff, 0xff, 0xff, 0xff)}, + {Name: "wlan0", Flags: up, HardwareAddr: mac(0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff)}, + }, + want: "aa:bb:cc:dd:ee:ff", + wantOk: true, + }, + { + name: "skips short hardware address", + ifaces: []net.Interface{ + {Name: "eth0", Flags: up, HardwareAddr: mac(1, 2, 3, 4)}, + {Name: "wlan0", Flags: up, HardwareAddr: mac(0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff)}, + }, + want: "aa:bb:cc:dd:ee:ff", + wantOk: true, + }, + { + name: "alphabetical sort: eth0 wins over wlan0", + ifaces: []net.Interface{ + {Name: "wlan0", Flags: up, HardwareAddr: mac(0xaa, 0, 0, 0, 0, 0x01)}, + {Name: "eth0", Flags: up, HardwareAddr: mac(0xbb, 0, 0, 0, 0, 0x02)}, + }, + want: "bb:00:00:00:00:02", + wantOk: true, + }, + { + name: "nothing eligible", + ifaces: []net.Interface{ + {Name: "lo", Flags: lo, HardwareAddr: mac(0, 0, 0, 0, 0, 0)}, + {Name: "eth0", Flags: down, HardwareAddr: mac(1, 1, 1, 1, 1, 1)}, + }, + want: "", + wantOk: false, + }, + { + name: "empty input", + ifaces: nil, + want: "", + wantOk: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := pickStableMAC(tt.ifaces) + if ok != tt.wantOk { + t.Errorf("ok = %v, want %v", ok, tt.wantOk) + } + if got != tt.want { + t.Errorf("mac = %q, want %q", got, tt.want) + } + }) + } +} + +type persistRecorder struct { + calls []string + err error +} + +func (p *persistRecorder) persist(id string) error { + p.calls = append(p.calls, id) + return p.err +} + +func withStubInterfaces(t *testing.T, stub func() []net.Interface) { + t.Helper() + prev := allInterfaces + allInterfaces = stub + t.Cleanup(func() { allInterfaces = prev }) +} + +func TestResolveClientID_OverrideWinsAndPersistsWhenDifferent(t *testing.T) { + rec := &persistRecorder{} + + id, err := ResolveClientID("my-override", "", rec.persist) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if id != "my-override" { + t.Errorf("id = %q, want my-override", id) + } + if len(rec.calls) != 1 || rec.calls[0] != "my-override" { + t.Errorf("persist calls = %v, want [my-override]", rec.calls) + } +} + +func TestResolveClientID_OverrideMatchingConfigDoesNotPersist(t *testing.T) { + rec := &persistRecorder{} + + id, err := ResolveClientID("same-id", "same-id", rec.persist) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if id != "same-id" { + t.Errorf("id = %q, want same-id", id) + } + if len(rec.calls) != 0 { + t.Errorf("persist calls = %v, want no-op when override matches config", rec.calls) + } +} + +func TestResolveClientID_ConfigBeatsMAC(t *testing.T) { + rec := &persistRecorder{} + withStubInterfaces(t, func() []net.Interface { + return []net.Interface{ + {Name: "eth0", Flags: net.FlagUp, HardwareAddr: mac(0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff)}, + } + }) + + id, err := ResolveClientID("", "from-config", rec.persist) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if id != "from-config" { + t.Errorf("id = %q, want from-config (config must beat MAC)", id) + } + if len(rec.calls) != 0 { + t.Errorf("persist calls = %v, want none (config path uses as-is)", rec.calls) + } +} + +func TestResolveClientID_MACNotPersisted(t *testing.T) { + rec := &persistRecorder{} + withStubInterfaces(t, func() []net.Interface { + return []net.Interface{ + {Name: "eth0", Flags: net.FlagUp, HardwareAddr: mac(0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff)}, + } + }) + + id, err := ResolveClientID("", "", rec.persist) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if id != "aa:bb:cc:dd:ee:ff" { + t.Errorf("id = %q, want MAC", id) + } + if len(rec.calls) != 0 { + t.Errorf("persist calls = %v, want none (MAC is inherently stable)", rec.calls) + } +} + +func TestResolveClientID_GeneratedUUIDPersisted(t *testing.T) { + rec := &persistRecorder{} + withStubInterfaces(t, func() []net.Interface { return nil }) + + id, err := ResolveClientID("", "", rec.persist) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if id == "" { + t.Fatal("resolve returned empty id") + } + if len(rec.calls) != 1 || rec.calls[0] != id { + t.Errorf("persist calls = %v, want [%q]", rec.calls, id) + } +} + +func TestResolveClientID_NilPersistStillReturnsID(t *testing.T) { + withStubInterfaces(t, func() []net.Interface { return nil }) + + id, err := ResolveClientID("", "", nil) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if id == "" { + t.Error("resolve with nil persist must still return a non-empty id") + } +} + +func TestResolveClientID_PersistErrorIsNotFatal(t *testing.T) { + rec := &persistRecorder{err: errors.New("disk full")} + withStubInterfaces(t, func() []net.Interface { return nil }) + + id, err := ResolveClientID("", "", rec.persist) + if err != nil { + t.Fatalf("resolve should not fail when persist fails: %v", err) + } + if id == "" { + t.Error("resolve returned empty id") + } + if len(rec.calls) != 1 { + t.Errorf("persist should have been attempted once, got %d calls", len(rec.calls)) + } +} diff --git a/third_party/sendspin-go/pkg/sendspin/config.go b/third_party/sendspin-go/pkg/sendspin/config.go new file mode 100644 index 0000000..a7e1fdb --- /dev/null +++ b/third_party/sendspin-go/pkg/sendspin/config.go @@ -0,0 +1,395 @@ +// ABOUTME: YAML config-file support for the player and server (paths, env overlay, write-back) +// ABOUTME: Flat keys 1:1 with CLI flags; precedence CLI > env > file > built-in default +package sendspin + +import ( + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "gopkg.in/yaml.v3" +) + +// PlayerEnvPrefix is the namespace for environment overrides of player config +// values. Env key = PlayerEnvPrefix + upper-snake(flag name). Example: +// "-buffer-ms" -> SENDSPIN_PLAYER_BUFFER_MS. +const PlayerEnvPrefix = "SENDSPIN_PLAYER_" + +// PlayerConfigFile mirrors the player's CLI flags. Fields with "zero" values +// that could reasonably be meaningful (bool, int) are pointers so absence in +// the YAML can be distinguished from an explicit false/0. +type PlayerConfigFile struct { + Name string `yaml:"name,omitempty"` + Server string `yaml:"server,omitempty"` + Port *int `yaml:"port,omitempty"` + BufferMs *int `yaml:"buffer_ms,omitempty"` + StaticDelayMs *int `yaml:"static_delay_ms,omitempty"` + LogFile string `yaml:"log_file,omitempty"` + NoTUI *bool `yaml:"no_tui,omitempty"` + StreamLogs *bool `yaml:"stream_logs,omitempty"` + ProductName string `yaml:"product_name,omitempty"` + Manufacturer string `yaml:"manufacturer,omitempty"` + NoReconnect *bool `yaml:"no_reconnect,omitempty"` + Daemon *bool `yaml:"daemon,omitempty"` + PreferredCodec string `yaml:"preferred_codec,omitempty"` + BufferCapacity *int `yaml:"buffer_capacity,omitempty"` + ClientID string `yaml:"client_id,omitempty"` + AudioDevice string `yaml:"audio_device,omitempty"` + MaxSampleRate *int `yaml:"max_sample_rate,omitempty"` + MaxBitDepth *int `yaml:"max_bit_depth,omitempty"` +} + +// loadYAMLConfig walks searchPaths, and for the first one that exists opens +// the file and unmarshals into out. It returns the path that was loaded, or +// empty if no candidate existed. A missing file is not an error; I/O or +// parse errors are returned as-is with the offending path attached. +func loadYAMLConfig(searchPaths []string, out any) (string, error) { + for _, candidate := range searchPaths { + if candidate == "" { + continue + } + data, err := os.ReadFile(candidate) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + return candidate, fmt.Errorf("read %s: %w", candidate, err) + } + if err := yaml.Unmarshal(data, out); err != nil { + return candidate, fmt.Errorf("parse %s: %w", candidate, err) + } + return candidate, nil + } + return "", nil +} + +// LoadPlayerConfig searches for a player.yaml and returns its parsed contents +// along with the path that was loaded (empty if none was found). +// +// Search order (first existing wins): +// 1. explicitPath if non-empty +// 2. $SENDSPIN_PLAYER_CONFIG if set +// 3. $XDG_CONFIG_HOME or OS equivalent + /sendspin/player.yaml +// 4. /etc/sendspin/player.yaml +// +// A missing file is not an error; the caller gets (nil, "", nil). +func LoadPlayerConfig(explicitPath string) (*PlayerConfigFile, string, error) { + var cfg PlayerConfigFile + used, err := loadYAMLConfig(playerConfigSearchPaths(explicitPath), &cfg) + if err != nil { + return nil, used, err + } + if used == "" { + return nil, "", nil + } + return &cfg, used, nil +} + +// userConfigPath returns /sendspin/. Matches the +// canonical path layout for both player.yaml and server.yaml. +func userConfigPath(relative string) (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("user config dir: %w", err) + } + return filepath.Join(dir, "sendspin", relative), nil +} + +// DefaultPlayerConfigPath returns the canonical user-level player.yaml path +// for this OS. Used when we need to auto-create the config for write-back. +func DefaultPlayerConfigPath() (string, error) { + return userConfigPath("player.yaml") +} + +func playerConfigSearchPaths(explicit string) []string { + paths := make([]string, 0, 4) + if explicit != "" { + paths = append(paths, explicit) + } + if env := os.Getenv("SENDSPIN_PLAYER_CONFIG"); env != "" { + paths = append(paths, env) + } + if p, err := userConfigPath("player.yaml"); err == nil { + paths = append(paths, p) + } + paths = append(paths, "/etc/sendspin/player.yaml") + return paths +} + +// ApplyEnvAndFile overlays env vars and YAML config-file values +// into the given FlagSet, but only for flags the user did NOT set on the CLI. +// Precedence: CLI (untouched here) > env > file > flag default. +// +// envPrefix is the namespace for env-var lookups (e.g. "SENDSPIN_PLAYER_"). +// fileValues is the flat flag-key → value map the caller derives from its +// typed config struct (see PlayerConfigFile.AsStringMap and +// ServerConfigFile.AsStringMap). A nil map is treated as empty. +// +// setByUser is typically built with flag.Visit before calling this. +func ApplyEnvAndFile(fs *flag.FlagSet, setByUser map[string]bool, envPrefix string, fileValues map[string]string) error { + var firstErr error + fs.VisitAll(func(f *flag.Flag) { + if firstErr != nil || setByUser[f.Name] { + return + } + envKey := envPrefix + strings.ToUpper(strings.ReplaceAll(f.Name, "-", "_")) + if val, ok := os.LookupEnv(envKey); ok { + if err := fs.Set(f.Name, val); err != nil { + firstErr = fmt.Errorf("env %s -> -%s: %w", envKey, f.Name, err) + } + return + } + configKey := strings.ReplaceAll(f.Name, "-", "_") + if val, ok := fileValues[configKey]; ok { + if err := fs.Set(f.Name, val); err != nil { + firstErr = fmt.Errorf("config %s -> -%s: %w", configKey, f.Name, err) + } + } + }) + return firstErr +} + +// AsStringMap returns only the keys the user actually set in the YAML, as +// strings suitable for flag.Set. Absent keys are omitted so the overlay +// correctly falls through to the flag default. +func (c *PlayerConfigFile) AsStringMap() map[string]string { + m := make(map[string]string) + if c == nil { + return m + } + if c.Name != "" { + m["name"] = c.Name + } + if c.Server != "" { + m["server"] = c.Server + } + if c.Port != nil { + m["port"] = strconv.Itoa(*c.Port) + } + if c.BufferMs != nil { + m["buffer_ms"] = strconv.Itoa(*c.BufferMs) + } + if c.StaticDelayMs != nil { + m["static_delay_ms"] = strconv.Itoa(*c.StaticDelayMs) + } + if c.LogFile != "" { + m["log_file"] = c.LogFile + } + if c.NoTUI != nil { + m["no_tui"] = strconv.FormatBool(*c.NoTUI) + } + if c.StreamLogs != nil { + m["stream_logs"] = strconv.FormatBool(*c.StreamLogs) + } + if c.ProductName != "" { + m["product_name"] = c.ProductName + } + if c.Manufacturer != "" { + m["manufacturer"] = c.Manufacturer + } + if c.NoReconnect != nil { + m["no_reconnect"] = strconv.FormatBool(*c.NoReconnect) + } + if c.Daemon != nil { + m["daemon"] = strconv.FormatBool(*c.Daemon) + } + if c.PreferredCodec != "" { + m["preferred_codec"] = c.PreferredCodec + } + if c.BufferCapacity != nil { + m["buffer_capacity"] = strconv.Itoa(*c.BufferCapacity) + } + if c.ClientID != "" { + m["client_id"] = c.ClientID + } + if c.AudioDevice != "" { + m["audio_device"] = c.AudioDevice + } + if c.MaxSampleRate != nil { + m["max_sample_rate"] = strconv.Itoa(*c.MaxSampleRate) + } + if c.MaxBitDepth != nil { + m["max_bit_depth"] = strconv.Itoa(*c.MaxBitDepth) + } + return m +} + +// WriteStringKey reads the YAML at path (if any), sets the given top-level +// string key to value, and atomically writes the result back. Comments and +// existing keys are preserved via yaml.Node round-tripping. Used to persist +// the auto-generated client_id and the --client-id override. +func WriteStringKey(path, key, value string) error { + var root yaml.Node + + if data, err := os.ReadFile(path); err == nil { + if err := yaml.Unmarshal(data, &root); err != nil { + return fmt.Errorf("parse existing config %s: %w", path, err) + } + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("read %s: %w", path, err) + } + + mapping := topLevelMapping(&root) + + setOrAppendStringKey(mapping, key, value) + + buf, err := yaml.Marshal(&root) + if err != nil { + return fmt.Errorf("marshal config: %w", err) + } + return atomicWriteFile(path, buf) +} + +// topLevelMapping returns the mapping node that backs the top of a YAML +// document. If root is empty or non-document, it's initialized in place. +func topLevelMapping(root *yaml.Node) *yaml.Node { + if root.Kind == yaml.DocumentNode && len(root.Content) > 0 && root.Content[0].Kind == yaml.MappingNode { + return root.Content[0] + } + mapping := &yaml.Node{Kind: yaml.MappingNode} + root.Kind = yaml.DocumentNode + root.Content = []*yaml.Node{mapping} + return mapping +} + +// setOrAppendStringKey updates the value for key in a MappingNode, or appends +// a new key/value pair if the key is not present. Leaves all other entries +// (and their comments) untouched. +func setOrAppendStringKey(mapping *yaml.Node, key, value string) { + for i := 0; i+1 < len(mapping.Content); i += 2 { + if mapping.Content[i].Value == key { + mapping.Content[i+1].Kind = yaml.ScalarNode + mapping.Content[i+1].Tag = "!!str" + mapping.Content[i+1].Value = value + mapping.Content[i+1].Style = 0 + return + } + } + mapping.Content = append(mapping.Content, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value}, + ) +} + +// ServerEnvPrefix is the namespace for environment overrides of server +// config values. Env key = ServerEnvPrefix + upper-snake(flag name). +// Example: "-no-mdns" -> SENDSPIN_SERVER_NO_MDNS. +const ServerEnvPrefix = "SENDSPIN_SERVER_" + +// ServerConfigFile mirrors the server's CLI flags. Fields with "zero" values +// that could reasonably be meaningful (bool) are pointers so absence in +// the YAML can be distinguished from an explicit false. +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"` +} + +// LoadServerConfig searches for a server.yaml and returns its parsed contents +// along with the path that was loaded (empty if none was found). +// +// Search order (first existing wins): +// 1. explicitPath if non-empty +// 2. $SENDSPIN_SERVER_CONFIG if set +// 3. $XDG_CONFIG_HOME or OS equivalent + /sendspin/server.yaml +// 4. /etc/sendspin/server.yaml +// +// A missing file is not an error; the caller gets (nil, "", nil). +func LoadServerConfig(explicitPath string) (*ServerConfigFile, string, error) { + var cfg ServerConfigFile + used, err := loadYAMLConfig(serverConfigSearchPaths(explicitPath), &cfg) + if err != nil { + return nil, used, err + } + if used == "" { + return nil, "", nil + } + return &cfg, used, nil +} + +// DefaultServerConfigPath returns the canonical user-level server.yaml path +// for this OS. +func DefaultServerConfigPath() (string, error) { + return userConfigPath("server.yaml") +} + +func serverConfigSearchPaths(explicit string) []string { + paths := make([]string, 0, 4) + if explicit != "" { + paths = append(paths, explicit) + } + if env := os.Getenv("SENDSPIN_SERVER_CONFIG"); env != "" { + paths = append(paths, env) + } + if p, err := userConfigPath("server.yaml"); err == nil { + paths = append(paths, p) + } + paths = append(paths, "/etc/sendspin/server.yaml") + return paths +} + +// AsStringMap returns only the keys the user actually set in the YAML, as +// strings suitable for flag.Set. Absent keys are omitted so the overlay +// correctly falls through to the flag default. +func (c *ServerConfigFile) AsStringMap() map[string]string { + m := make(map[string]string) + if c == nil { + return m + } + if c.Name != "" { + m["name"] = c.Name + } + if c.Port != nil { + m["port"] = strconv.Itoa(*c.Port) + } + if c.LogFile != "" { + m["log_file"] = c.LogFile + } + if c.Debug != nil { + m["debug"] = strconv.FormatBool(*c.Debug) + } + if c.NoMDNS != nil { + m["no_mdns"] = strconv.FormatBool(*c.NoMDNS) + } + if c.NoTUI != nil { + m["no_tui"] = strconv.FormatBool(*c.NoTUI) + } + if c.Audio != "" { + m["audio"] = c.Audio + } + if c.DiscoverClients != nil { + m["discover_clients"] = strconv.FormatBool(*c.DiscoverClients) + } + if c.Daemon != nil { + m["daemon"] = strconv.FormatBool(*c.Daemon) + } + return m +} + +// atomicWriteFile writes data to path via tempfile + rename. Matches the +// atomicity guarantees the old writePersistedClientID used to provide. +func atomicWriteFile(path string, data []byte) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("mkdir %s: %w", dir, err) + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return fmt.Errorf("write temp: %w", err) + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("rename: %w", err) + } + return nil +} diff --git a/third_party/sendspin-go/pkg/sendspin/config_server_test.go b/third_party/sendspin-go/pkg/sendspin/config_server_test.go new file mode 100644 index 0000000..1a42b80 --- /dev/null +++ b/third_party/sendspin-go/pkg/sendspin/config_server_test.go @@ -0,0 +1,119 @@ +// ABOUTME: Tests for YAML config loading and env prefix routing for sendspin-server +package sendspin + +import ( + "flag" + "os" + "path/filepath" + "testing" +) + +func TestLoadServerConfig_ExplicitPathWithAllKeys(t *testing.T) { + path := filepath.Join(t.TempDir(), "server.yaml") + body := `# Test server +name: "Living Room Server" +port: 9000 +log_file: "custom-server.log" +debug: true +no_mdns: true +no_tui: true +audio: "/srv/music/radio.m3u8" +discover_clients: true +daemon: true +` + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + + cfg, used, err := LoadServerConfig(path) + if err != nil { + t.Fatalf("load: %v", err) + } + if used != path { + t.Errorf("used = %q, want %q", used, path) + } + if cfg == nil { + t.Fatal("cfg is nil") + } + if cfg.Name != "Living Room Server" { + t.Errorf("name = %q", cfg.Name) + } + if cfg.Port == nil || *cfg.Port != 9000 { + t.Errorf("port = %v, want 9000", cfg.Port) + } + if cfg.LogFile != "custom-server.log" { + t.Errorf("log_file = %q", cfg.LogFile) + } + if cfg.Debug == nil || !*cfg.Debug { + t.Errorf("debug = %v, want true", cfg.Debug) + } + if cfg.NoMDNS == nil || !*cfg.NoMDNS { + t.Errorf("no_mdns = %v, want true", cfg.NoMDNS) + } + if cfg.NoTUI == nil || !*cfg.NoTUI { + t.Errorf("no_tui = %v, want true", cfg.NoTUI) + } + if cfg.Audio != "/srv/music/radio.m3u8" { + t.Errorf("audio = %q", cfg.Audio) + } + if cfg.DiscoverClients == nil || !*cfg.DiscoverClients { + t.Errorf("discover_clients = %v, want true", cfg.DiscoverClients) + } + if cfg.Daemon == nil || !*cfg.Daemon { + t.Errorf("daemon = %v, want true", cfg.Daemon) + } +} + +func TestLoadServerConfig_MissingFileIsNotAnError(t *testing.T) { + cfg, used, err := LoadServerConfig(filepath.Join(t.TempDir(), "does-not-exist.yaml")) + if err != nil { + t.Fatalf("load: %v", err) + } + if cfg != nil || used != "" { + t.Errorf("expected nil/empty for missing file, got cfg=%v path=%q", cfg, used) + } +} + +func TestLoadServerConfig_EnvPathHonored(t *testing.T) { + dir := t.TempDir() + envPath := filepath.Join(dir, "from-env-server.yaml") + if err := os.WriteFile(envPath, []byte("name: EnvServer\n"), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + t.Setenv("SENDSPIN_SERVER_CONFIG", envPath) + + cfg, used, err := LoadServerConfig("") + if err != nil { + t.Fatalf("load: %v", err) + } + if used != envPath { + t.Errorf("used = %q, want %q", used, envPath) + } + if cfg.Name != "EnvServer" { + t.Errorf("name = %q", cfg.Name) + } +} + +// TestApplyEnvAndFile_ServerEnvPrefix confirms the generalized envPrefix +// parameter routes SENDSPIN_SERVER_* correctly. Precedence rules themselves +// are already covered by the player tests; this is pure plumbing. +func TestApplyEnvAndFile_ServerEnvPrefix(t *testing.T) { + fs := flag.NewFlagSet("server", flag.ContinueOnError) + port := fs.Int("port", 8927, "port") + audio := fs.String("audio", "", "audio source") + if err := fs.Parse(nil); err != nil { + t.Fatalf("parse: %v", err) + } + t.Setenv("SENDSPIN_SERVER_PORT", "9999") + t.Setenv("SENDSPIN_SERVER_AUDIO", "/srv/env.flac") + + if err := ApplyEnvAndFile(fs, map[string]bool{}, ServerEnvPrefix, nil); err != nil { + t.Fatalf("apply: %v", err) + } + if *port != 9999 { + t.Errorf("port = %d, want 9999", *port) + } + if *audio != "/srv/env.flac" { + t.Errorf("audio = %q", *audio) + } +} diff --git a/third_party/sendspin-go/pkg/sendspin/config_test.go b/third_party/sendspin-go/pkg/sendspin/config_test.go new file mode 100644 index 0000000..02dfc5f --- /dev/null +++ b/third_party/sendspin-go/pkg/sendspin/config_test.go @@ -0,0 +1,397 @@ +// ABOUTME: Tests for YAML config loading, env/file overlay precedence, and write-back round-trip +package sendspin + +import ( + "flag" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoadPlayerConfig_ExplicitPathWithAllKeys(t *testing.T) { + path := filepath.Join(t.TempDir(), "player.yaml") + body := `# My config +name: "Kitchen" +server: "192.168.1.100:8927" +port: 8999 +buffer_ms: 250 +static_delay_ms: 10 +log_file: "custom.log" +no_tui: true +stream_logs: false +product_name: "Test Speaker" +manufacturer: "Acme" +no_reconnect: true +daemon: false +preferred_codec: "flac" +buffer_capacity: 2097152 +client_id: "aa:bb:cc:dd:ee:ff" +audio_device: "USB Audio Device" +` + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + + cfg, used, err := LoadPlayerConfig(path) + if err != nil { + t.Fatalf("load: %v", err) + } + if used != path { + t.Errorf("used path = %q, want %q", used, path) + } + if cfg == nil { + t.Fatal("cfg is nil") + } + if cfg.Name != "Kitchen" || cfg.Server != "192.168.1.100:8927" { + t.Errorf("string keys: %+v", cfg) + } + if cfg.Port == nil || *cfg.Port != 8999 { + t.Errorf("port = %v, want 8999", cfg.Port) + } + if cfg.NoTUI == nil || !*cfg.NoTUI { + t.Errorf("no_tui = %v, want true", cfg.NoTUI) + } + if cfg.StreamLogs == nil || *cfg.StreamLogs { + t.Errorf("stream_logs = %v, want false", cfg.StreamLogs) + } + if cfg.ClientID != "aa:bb:cc:dd:ee:ff" { + t.Errorf("client_id = %q", cfg.ClientID) + } + if cfg.AudioDevice != "USB Audio Device" { + t.Errorf("audio_device = %q", cfg.AudioDevice) + } +} + +func TestLoadPlayerConfig_MissingFileIsNotAnError(t *testing.T) { + cfg, used, err := LoadPlayerConfig(filepath.Join(t.TempDir(), "does-not-exist.yaml")) + if err != nil { + t.Fatalf("load: %v", err) + } + if cfg != nil || used != "" { + t.Errorf("expected nil/empty for missing file, got cfg=%v path=%q", cfg, used) + } +} + +func TestLoadPlayerConfig_InvalidYAMLIsAnError(t *testing.T) { + path := filepath.Join(t.TempDir(), "player.yaml") + if err := os.WriteFile(path, []byte("not: valid: : yaml"), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + _, _, err := LoadPlayerConfig(path) + if err == nil { + t.Fatal("expected parse error") + } +} + +func TestLoadPlayerConfig_EnvPathHonored(t *testing.T) { + dir := t.TempDir() + envPath := filepath.Join(dir, "from-env.yaml") + if err := os.WriteFile(envPath, []byte("name: EnvName\n"), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + t.Setenv("SENDSPIN_PLAYER_CONFIG", envPath) + + cfg, used, err := LoadPlayerConfig("") + if err != nil { + t.Fatalf("load: %v", err) + } + if used != envPath { + t.Errorf("used = %q, want %q", used, envPath) + } + if cfg.Name != "EnvName" { + t.Errorf("name = %q", cfg.Name) + } +} + +// applyPlayerFlags builds a flag.FlagSet mirroring the real player CLI so +// ApplyEnvAndFile can be tested end-to-end. +func newTestFlagSet() (*flag.FlagSet, map[string]*string, map[string]*int, map[string]*bool) { + fs := flag.NewFlagSet("player", flag.ContinueOnError) + strs := map[string]*string{ + "name": fs.String("name", "", "player name"), + "server": fs.String("server", "", "server addr"), + "log-file": fs.String("log-file", "default.log", "log file"), + "product-name": fs.String("product-name", "", "product name"), + "manufacturer": fs.String("manufacturer", "", "mfg"), + "preferred-codec": fs.String("preferred-codec", "", "codec"), + "client-id": fs.String("client-id", "", "client id"), + } + ints := map[string]*int{ + "port": fs.Int("port", 8927, "port"), + "buffer-ms": fs.Int("buffer-ms", 150, "buffer ms"), + "static-delay-ms": fs.Int("static-delay-ms", 0, "static delay"), + "buffer-capacity": fs.Int("buffer-capacity", 1048576, "buffer cap"), + "max-sample-rate": fs.Int("max-sample-rate", 0, "max sample rate"), + "max-bit-depth": fs.Int("max-bit-depth", 0, "max bit depth"), + } + bools := map[string]*bool{ + "no-tui": fs.Bool("no-tui", false, "no tui"), + "stream-logs": fs.Bool("stream-logs", false, "stream logs"), + "no-reconnect": fs.Bool("no-reconnect", false, "no reconnect"), + "daemon": fs.Bool("daemon", false, "daemon"), + } + return fs, strs, ints, bools +} + +func TestApplyEnvAndFile_FileFillsUnsetFlags(t *testing.T) { + fs, strs, ints, bools := newTestFlagSet() + // Simulate user passing only "-name Foo" + if err := fs.Parse([]string{"-name", "CLI-Name"}); err != nil { + t.Fatalf("parse: %v", err) + } + setByUser := map[string]bool{"name": true} + + port := 9000 + noTUI := true + cfg := &PlayerConfigFile{ + Server: "file.example:1234", + Port: &port, + NoTUI: &noTUI, + } + + if err := ApplyEnvAndFile(fs, setByUser, PlayerEnvPrefix, cfg.AsStringMap()); err != nil { + t.Fatalf("apply: %v", err) + } + if *strs["name"] != "CLI-Name" { + t.Errorf("CLI should win: name = %q", *strs["name"]) + } + if *strs["server"] != "file.example:1234" { + t.Errorf("file should fill: server = %q", *strs["server"]) + } + if *ints["port"] != 9000 { + t.Errorf("file should fill: port = %d", *ints["port"]) + } + if !*bools["no-tui"] { + t.Errorf("file should fill: no_tui = %v", *bools["no-tui"]) + } + // Unset elsewhere keeps default + if *ints["buffer-ms"] != 150 { + t.Errorf("default should stick: buffer-ms = %d", *ints["buffer-ms"]) + } +} + +func TestApplyEnvAndFile_EnvBeatsFile(t *testing.T) { + fs, strs, ints, _ := newTestFlagSet() + if err := fs.Parse(nil); err != nil { + t.Fatalf("parse: %v", err) + } + t.Setenv("SENDSPIN_PLAYER_SERVER", "env.example:9999") + t.Setenv("SENDSPIN_PLAYER_PORT", "5555") + + port := 9000 + cfg := &PlayerConfigFile{ + Server: "file.example:1234", + Port: &port, + } + + if err := ApplyEnvAndFile(fs, map[string]bool{}, PlayerEnvPrefix, cfg.AsStringMap()); err != nil { + t.Fatalf("apply: %v", err) + } + if *strs["server"] != "env.example:9999" { + t.Errorf("env should beat file: server = %q", *strs["server"]) + } + if *ints["port"] != 5555 { + t.Errorf("env should beat file: port = %d", *ints["port"]) + } +} + +func TestApplyEnvAndFile_NilConfigStillHonorsEnv(t *testing.T) { + fs, strs, _, _ := newTestFlagSet() + if err := fs.Parse(nil); err != nil { + t.Fatalf("parse: %v", err) + } + t.Setenv("SENDSPIN_PLAYER_NAME", "env-only") + + if err := ApplyEnvAndFile(fs, map[string]bool{}, PlayerEnvPrefix, nil); err != nil { + t.Fatalf("apply: %v", err) + } + if *strs["name"] != "env-only" { + t.Errorf("name = %q, want env-only", *strs["name"]) + } +} + +func TestApplyEnvAndFile_InvalidEnvReturnsError(t *testing.T) { + fs, _, _, _ := newTestFlagSet() + if err := fs.Parse(nil); err != nil { + t.Fatalf("parse: %v", err) + } + t.Setenv("SENDSPIN_PLAYER_PORT", "not-a-number") + + err := ApplyEnvAndFile(fs, map[string]bool{}, PlayerEnvPrefix, nil) + if err == nil { + t.Fatal("expected error on invalid env int") + } + if !strings.Contains(err.Error(), "port") { + t.Errorf("error should mention the offending flag: %v", err) + } +} + +// TestApplyEnvAndFile_MaxSampleRateAndBitDepth covers the output-capability +// override keys end-to-end: file value flows through, env beats file, CLI +// beats env. Same precedence as every other key, but worth a dedicated test: +// these are the operator's escape hatch when the auto-probe is wrong, and a +// silent bug here means users can't override. +func TestApplyEnvAndFile_MaxSampleRateAndBitDepth(t *testing.T) { + t.Run("file fills both", func(t *testing.T) { + fs, _, ints, _ := newTestFlagSet() + if err := fs.Parse(nil); err != nil { + t.Fatalf("parse: %v", err) + } + rate, depth := 48000, 16 + cfg := &PlayerConfigFile{MaxSampleRate: &rate, MaxBitDepth: &depth} + if err := ApplyEnvAndFile(fs, map[string]bool{}, PlayerEnvPrefix, cfg.AsStringMap()); err != nil { + t.Fatalf("apply: %v", err) + } + if *ints["max-sample-rate"] != 48000 { + t.Errorf("max-sample-rate = %d, want 48000", *ints["max-sample-rate"]) + } + if *ints["max-bit-depth"] != 16 { + t.Errorf("max-bit-depth = %d, want 16", *ints["max-bit-depth"]) + } + }) + + t.Run("env beats file", func(t *testing.T) { + fs, _, ints, _ := newTestFlagSet() + if err := fs.Parse(nil); err != nil { + t.Fatalf("parse: %v", err) + } + t.Setenv("SENDSPIN_PLAYER_MAX_SAMPLE_RATE", "96000") + rate := 48000 + cfg := &PlayerConfigFile{MaxSampleRate: &rate} + if err := ApplyEnvAndFile(fs, map[string]bool{}, PlayerEnvPrefix, cfg.AsStringMap()); err != nil { + t.Fatalf("apply: %v", err) + } + if *ints["max-sample-rate"] != 96000 { + t.Errorf("env should beat file: max-sample-rate = %d", *ints["max-sample-rate"]) + } + }) + + t.Run("CLI beats env and file", func(t *testing.T) { + fs, _, ints, _ := newTestFlagSet() + if err := fs.Parse([]string{"-max-sample-rate", "192000"}); err != nil { + t.Fatalf("parse: %v", err) + } + setByUser := map[string]bool{"max-sample-rate": true} + t.Setenv("SENDSPIN_PLAYER_MAX_SAMPLE_RATE", "96000") + rate := 48000 + cfg := &PlayerConfigFile{MaxSampleRate: &rate} + if err := ApplyEnvAndFile(fs, setByUser, PlayerEnvPrefix, cfg.AsStringMap()); err != nil { + t.Fatalf("apply: %v", err) + } + if *ints["max-sample-rate"] != 192000 { + t.Errorf("CLI should win: max-sample-rate = %d", *ints["max-sample-rate"]) + } + }) +} + +// TestPlayerConfigFile_AsStringMap_OmitsUnsetCaps guards a subtle precedence +// bug: if AsStringMap emitted "0" for an unset *int field, that "0" would +// flow through ApplyEnvAndFile as an explicit value and clobber any flag +// default, env override, or CLI flag the user actually set. The pointer-vs- +// nil distinction in the YAML field is the only thing keeping the precedence +// chain honest, and AsStringMap must respect it. +func TestPlayerConfigFile_AsStringMap_OmitsUnsetCaps(t *testing.T) { + cfg := &PlayerConfigFile{} // all fields nil/empty + m := cfg.AsStringMap() + if _, ok := m["max_sample_rate"]; ok { + t.Error("max_sample_rate should be absent when not set in YAML") + } + if _, ok := m["max_bit_depth"]; ok { + t.Error("max_bit_depth should be absent when not set in YAML") + } + + // Set explicitly to zero — the user genuinely wants 0, which means + // "no cap, auto-probe". Pointer is non-nil so the key DOES appear. + zero := 0 + cfg = &PlayerConfigFile{MaxSampleRate: &zero, MaxBitDepth: &zero} + m = cfg.AsStringMap() + if got := m["max_sample_rate"]; got != "0" { + t.Errorf("explicit zero should round-trip; got %q", got) + } + if got := m["max_bit_depth"]; got != "0" { + t.Errorf("explicit zero should round-trip; got %q", got) + } +} + +func TestWriteStringKey_CreatesFileWithSingleKey(t *testing.T) { + path := filepath.Join(t.TempDir(), "nested", "player.yaml") + + if err := WriteStringKey(path, "client_id", "aa:bb:cc:dd:ee:ff"); err != nil { + t.Fatalf("write: %v", err) + } + + cfg, _, err := LoadPlayerConfig(path) + if err != nil { + t.Fatalf("load after write: %v", err) + } + if cfg.ClientID != "aa:bb:cc:dd:ee:ff" { + t.Errorf("client_id = %q", cfg.ClientID) + } + if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) { + t.Errorf("tempfile should be cleaned up: %v", err) + } +} + +func TestWriteStringKey_UpdatesExistingKeyPreservingOthersAndComments(t *testing.T) { + path := filepath.Join(t.TempDir(), "player.yaml") + initial := `# Living room speaker +name: "Living Room" +server: "192.168.1.100:8927" +client_id: "old-id" +buffer_ms: 250 +` + if err := os.WriteFile(path, []byte(initial), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + + if err := WriteStringKey(path, "client_id", "new-id"); err != nil { + t.Fatalf("write: %v", err) + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read: %v", err) + } + text := string(got) + + if !strings.Contains(text, `client_id: "new-id"`) && !strings.Contains(text, `client_id: new-id`) { + t.Errorf("client_id not updated in file:\n%s", text) + } + if strings.Contains(text, "old-id") { + t.Errorf("old value still present:\n%s", text) + } + // Other keys preserved. + for _, want := range []string{"Living Room", "192.168.1.100:8927", "buffer_ms"} { + if !strings.Contains(text, want) { + t.Errorf("missing preserved content %q in:\n%s", want, text) + } + } + // The leading comment survives round-trip. + if !strings.Contains(text, "# Living room speaker") { + t.Errorf("leading comment lost:\n%s", text) + } +} + +func TestWriteStringKey_AppendsKeyWhenAbsent(t *testing.T) { + path := filepath.Join(t.TempDir(), "player.yaml") + initial := "name: Kitchen\n" + if err := os.WriteFile(path, []byte(initial), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + + if err := WriteStringKey(path, "client_id", "new-value"); err != nil { + t.Fatalf("write: %v", err) + } + + cfg, _, err := LoadPlayerConfig(path) + if err != nil { + t.Fatalf("load: %v", err) + } + if cfg.Name != "Kitchen" { + t.Errorf("original key lost: name = %q", cfg.Name) + } + if cfg.ClientID != "new-value" { + t.Errorf("new key missing: client_id = %q", cfg.ClientID) + } +} diff --git a/third_party/sendspin-go/pkg/sendspin/doc.go b/third_party/sendspin-go/pkg/sendspin/doc.go new file mode 100644 index 0000000..02426b1 --- /dev/null +++ b/third_party/sendspin-go/pkg/sendspin/doc.go @@ -0,0 +1,30 @@ +// ABOUTME: High-level Sendspin library API +// ABOUTME: Provides simple Player and Server APIs for most use cases +// Package sendspin provides high-level APIs for Sendspin audio streaming. +// +// This is the main entry point for most library users, providing: +// - Player: Connect to servers and play synchronized audio +// - Server: Serve audio to multiple clients +// - AudioSource: Interface for custom audio sources +// +// For lower-level control, see the audio, protocol, sync, and discovery packages. +// +// Example Player: +// +// player, err := sendspin.NewPlayer(sendspin.PlayerConfig{ +// ServerAddr: "localhost:8927", +// PlayerName: "Living Room", +// Volume: 80, +// }) +// err = player.Connect() +// err = player.Play() +// +// Example Server: +// +// source, err := sendspin.FileSource("/path/to/audio.flac") +// server, err := sendspin.NewServer(sendspin.ServerConfig{ +// Port: 8927, +// Source: source, +// }) +// err = server.Start() +package sendspin diff --git a/third_party/sendspin-go/pkg/sendspin/group.go b/third_party/sendspin-go/pkg/sendspin/group.go new file mode 100644 index 0000000..ce56b5a --- /dev/null +++ b/third_party/sendspin-go/pkg/sendspin/group.go @@ -0,0 +1,300 @@ +// ABOUTME: Group owns the per-playback-group event bus +// ABOUTME: Publishes client-level events to subscribed GroupRole handlers +package sendspin + +import ( + "log" + "sync" + + "github.com/Sendspin/sendspin-go/pkg/protocol" +) + +// Event is the sealed interface implemented by every event published on a +// Group's event bus. Callers type-switch on the interface to handle the +// concrete types they care about. +// +// The isGroupEvent marker is unexported, so third-party packages cannot +// implement Event directly — all events must be defined in this package. +type Event interface { + isGroupEvent() +} + +// ClientJoinedEvent fires when a client has completed the handshake and +// been added to a Group. Handlers that need to send a greeting message +// or snapshot state to the new client should listen for this. +// +// Unlike ClientLeftEvent, this event carries a live *ServerClient +// pointer because at publish time the client has just completed its +// handshake and its connection is fully alive — handler calls like +// c.Send(...) are safe. ClientLeftEvent intentionally drops the +// pointer because by the time it fires, the client is mid-teardown. +type ClientJoinedEvent struct { + Client *ServerClient +} + +func (ClientJoinedEvent) isGroupEvent() {} + +// ClientLeftEvent fires when a client has disconnected and been removed +// from a Group. The event carries only the ID and name of the departed +// client, not a pointer — by the time handlers see this event, the +// underlying ServerClient may already be mid-teardown and its methods +// are unsafe to call. Handlers that need per-client state must have +// captured it earlier (e.g. on the matching ClientJoinedEvent). +type ClientLeftEvent struct { + ClientID string + ClientName string +} + +func (ClientLeftEvent) isGroupEvent() {} + +// ClientStateChangedEvent fires when a client's player state (state, +// volume, muted) has been updated from a client/state control message. +// The snapshot fields are captured at publish time; the Client pointer +// is provided for handlers that need to reply. +// +// Handlers should treat the embedded State/Volume/Muted fields as the +// authoritative value for this specific event; calling c.State() on +// the Client pointer may return a newer value written by a subsequent +// state update that raced this event's delivery. +type ClientStateChangedEvent struct { + Client *ServerClient + State string + Volume int + Muted bool +} + +func (ClientStateChangedEvent) isGroupEvent() {} + +// GroupPlaybackStateChangedEvent fires when the group's playback state +// transitions. Roles that need to react (e.g. re-broadcast metadata at +// stopped→playing) listen via the optional PlaybackStateChangedHandler +// interface dispatched by Group's role event loop. +// +// OldState may be empty for the initial transition out of the zero value. +// Same-state transitions are not published (no-op writes are silent). +type GroupPlaybackStateChangedEvent struct { + OldState string + NewState string +} + +func (GroupPlaybackStateChangedEvent) isGroupEvent() {} + +// Group owns the event bus and the set of clients currently attached to +// a playback group. For M2 there is exactly one Group per Server, +// auto-created in NewServer. Multi-group support is a post-#61 concern. +// +// Event ordering: events originating from a single source (e.g., all +// events from one client's read loop) are delivered to each subscriber +// in publish order. Events from different sources have no relative +// ordering guarantee — a handler that reacts to ClientJoinedEvent by +// publishing another event does not race the client's own subsequent +// events deterministically. M3 role handlers that need cross-source +// ordering must coordinate via their own synchronization. +// +// The zero value is not usable — construct via NewGroup. +type Group struct { + id string + playbackState string + + mu sync.RWMutex + clients map[string]*ServerClient + subs map[int]chan Event + nextSub int + closed bool + + roles map[string]GroupRole + roleDispatchStarted bool +} + +// NewGroup constructs a Group with the given identifier. The ID is +// typically the server's UUID for the implicit default group. +func NewGroup(id string) *Group { + return &Group{ + id: id, + playbackState: "playing", + clients: make(map[string]*ServerClient), + subs: make(map[int]chan Event), + } +} + +// ID returns the group identifier. +func (g *Group) ID() string { return g.id } + +// Subscribe registers a new listener and returns the event channel plus +// an unsubscribe function. Each Subscribe call gets its own buffered +// channel (capacity 32). Calling unsubscribe closes the channel and +// removes it from the fan-out list; calling it more than once is safe. +// +// After Close() has been called, Subscribe returns a pre-closed channel +// and a no-op unsubscribe. A receive on the returned channel will yield +// the zero value with ok == false — callers ranging over the channel +// should treat this as "group has shut down" rather than "no events +// yet." +func (g *Group) Subscribe() (<-chan Event, func()) { + g.mu.Lock() + defer g.mu.Unlock() + + if g.closed { + // Return a closed channel and a no-op unsubscribe so callers + // don't have to check for this case. + ch := make(chan Event) + close(ch) + return ch, func() {} + } + + id := g.nextSub + g.nextSub++ + // Buffer size 32 is a heuristic: the bus carries low-rate control + // events (joins, leaves, state changes), not audio. Slow handlers + // drop events via the non-blocking publish path rather than stalling + // the publisher. + ch := make(chan Event, 32) + g.subs[id] = ch + + var once sync.Once + unsubscribe := func() { + once.Do(func() { + g.mu.Lock() + defer g.mu.Unlock() + if existing, ok := g.subs[id]; ok { + delete(g.subs, id) + close(existing) + } + }) + } + return ch, unsubscribe +} + +// publish fans an event out to every active subscriber. This is the +// top-level entry point used by code that does not already hold g.mu. +// Sends are non-blocking: if a subscriber's buffer is full, the event +// is dropped and a warning is logged. +func (g *Group) publish(evt Event) { + g.mu.RLock() + defer g.mu.RUnlock() + g.publishLocked(evt) +} + +// publishLocked fans an event out assuming the caller already holds +// g.mu (either Lock or RLock). Use this from code that mutates g.subs +// or g.clients and wants to publish under the same critical section +// to preserve ordering. Sends are non-blocking for the same reason +// publish is — slow subscribers drop events instead of stalling the +// publisher. +// +// Note: when invoked under a write Lock (as addClient/removeClient do), +// the non-blocking sends run while the writer lock is held. This is +// bounded by the number of subscribers and each send is select-default, +// so the critical section remains O(subscribers) with no blocking. +func (g *Group) publishLocked(evt Event) { + if g.closed { + return + } + + for id, ch := range g.subs { + select { + case ch <- evt: + default: + log.Printf("Group %s: subscriber %d dropped event %T (buffer full)", g.id, id, evt) + } + } +} + +// addClient attaches a ServerClient to the group and publishes a +// ClientJoinedEvent. Idempotent — adding the same client twice is a +// no-op on the second call. +func (g *Group) addClient(c *ServerClient) { + g.mu.Lock() + defer g.mu.Unlock() + + if g.closed { + return + } + if _, exists := g.clients[c.ID()]; exists { + return + } + g.clients[c.ID()] = c + + // Send group/update to the joining client — group-level concern, + // not role-specific. Sent before ClientJoinedEvent so role handlers + // can assume the client already knows its group context. + groupID := g.id + playbackState := g.playbackState + if playbackState == "" { + playbackState = "playing" + } + c.Send("group/update", protocol.GroupUpdate{ + GroupID: &groupID, + PlaybackState: &playbackState, + }) + + g.publishLocked(ClientJoinedEvent{Client: c}) +} + +// removeClient detaches a ServerClient and publishes a ClientLeftEvent. +// Idempotent — removing an unknown client is a no-op. +func (g *Group) removeClient(c *ServerClient) { + g.mu.Lock() + defer g.mu.Unlock() + + if _, exists := g.clients[c.ID()]; !exists { + return + } + delete(g.clients, c.ID()) + + g.publishLocked(ClientLeftEvent{ + ClientID: c.ID(), + ClientName: c.Name(), + }) +} + +// Clients returns a snapshot of the ServerClients currently attached to +// this group. The returned slice is a fresh copy; mutating it does not +// affect the group. +func (g *Group) Clients() []*ServerClient { + g.mu.RLock() + defer g.mu.RUnlock() + + out := make([]*ServerClient, 0, len(g.clients)) + for _, c := range g.clients { + out = append(out, c) + } + return out +} + +// Close releases all subscribers and marks the group as shut down. +// After Close, Subscribe returns a pre-closed channel and publish is a +// no-op. Close is safe to call multiple times. +func (g *Group) Close() { + g.mu.Lock() + defer g.mu.Unlock() + + if g.closed { + return + } + g.closed = true + + for id, ch := range g.subs { + close(ch) + delete(g.subs, id) + } + clear(g.clients) +} + +// SetPlaybackState updates the group's playback state. If the new state +// differs from the current value, a GroupPlaybackStateChangedEvent is +// published. Future clients joining the group will receive the new +// state in group/update. Same-state writes are a silent no-op. +func (g *Group) SetPlaybackState(state string) { + g.mu.Lock() + defer g.mu.Unlock() + if g.playbackState == state { + return + } + oldState := g.playbackState + g.playbackState = state + g.publishLocked(GroupPlaybackStateChangedEvent{ + OldState: oldState, + NewState: state, + }) +} diff --git a/third_party/sendspin-go/pkg/sendspin/group_role.go b/third_party/sendspin-go/pkg/sendspin/group_role.go new file mode 100644 index 0000000..4a76ccc --- /dev/null +++ b/third_party/sendspin-go/pkg/sendspin/group_role.go @@ -0,0 +1,163 @@ +// ABOUTME: GroupRole interface and role dispatch on Group +// ABOUTME: Roles register with the Group and receive client events + messages +package sendspin + +import ( + "encoding/json" + "fmt" +) + +// GroupRole is the interface implemented by per-role handlers. Each +// role family (controller, metadata, player, artwork) registers one +// GroupRole with the Group. The Group dispatches client lifecycle +// events to all registered roles. +type GroupRole interface { + Role() string + OnClientJoin(c *ServerClient) + OnClientLeave(id string, name string) +} + +// MessageHandler is an optional interface that a GroupRole may +// implement to receive role-specific messages (e.g., client/command +// payloads routed by role key). Roles that don't handle messages +// don't need to implement this. +type MessageHandler interface { + HandleMessage(c *ServerClient, payload json.RawMessage) error +} + +// PlaybackStateChangedHandler is an optional interface that a GroupRole +// may implement to receive notifications when the group's playback state +// transitions. The Group dispatches GroupPlaybackStateChangedEvent to +// roles that implement this interface; same-state writes are not +// dispatched (Group.SetPlaybackState filters them out). +// +// Note: oldState may be empty for the very first transition out of the +// zero value. +type PlaybackStateChangedHandler interface { + OnPlaybackStateChanged(oldState, newState string) +} + +// RegisterRole registers a GroupRole with this group. The role is +// stored by its Role() family name. Duplicate registrations for the +// same family overwrite the previous one. +// +// After registration, the role receives OnClientJoin / OnClientLeave +// calls for clients that join or leave the group, dispatched from +// the group's internal event subscriber. +func (g *Group) RegisterRole(role GroupRole) { + g.mu.Lock() + defer g.mu.Unlock() + + if g.roles == nil { + g.roles = make(map[string]GroupRole) + } + g.roles[role.Role()] = role + + // Optional attach-to-group hook: roles that need to iterate the + // group's clients on broadcast (e.g. MetadataGroupRole) implement + // the unexported attachToGroup method to receive a back-reference. + // Kept unexported so callers can't bypass RegisterRole. + if a, ok := role.(interface{ attachToGroup(*Group) }); ok { + a.attachToGroup(g) + } + + if g.roleDispatchStarted { + return + } + g.roleDispatchStarted = true + + events, _ := g.subscribeInternal() + go g.dispatchRoleEvents(events) +} + +// GetRole returns the registered GroupRole for the given family name, +// or nil if none is registered. +func (g *Group) GetRole(family string) GroupRole { + g.mu.RLock() + defer g.mu.RUnlock() + if g.roles == nil { + return nil + } + return g.roles[family] +} + +// RouteMessage routes a role-specific message payload to the +// registered GroupRole for the given family. Returns an error if no +// role is registered for the family or if the role doesn't implement +// MessageHandler. +func (g *Group) RouteMessage(c *ServerClient, roleFamily string, payload json.RawMessage) error { + g.mu.RLock() + role, ok := g.roles[roleFamily] + g.mu.RUnlock() + + if !ok { + return fmt.Errorf("no role registered for %q", roleFamily) + } + + handler, ok := role.(MessageHandler) + if !ok { + return fmt.Errorf("role %q does not handle messages", roleFamily) + } + + return handler.HandleMessage(c, payload) +} + +// dispatchRoleEvents reads from the group's event bus and calls +// the appropriate lifecycle method on every registered role. +func (g *Group) dispatchRoleEvents(events <-chan Event) { + for evt := range events { + g.mu.RLock() + roles := make([]GroupRole, 0, len(g.roles)) + for _, r := range g.roles { + roles = append(roles, r) + } + g.mu.RUnlock() + + switch e := evt.(type) { + case ClientJoinedEvent: + for _, r := range roles { + r.OnClientJoin(e.Client) + } + case ClientLeftEvent: + for _, r := range roles { + r.OnClientLeave(e.ClientID, e.ClientName) + } + case GroupPlaybackStateChangedEvent: + for _, r := range roles { + if h, ok := r.(PlaybackStateChangedHandler); ok { + h.OnPlaybackStateChanged(e.OldState, e.NewState) + } + } + default: + // ClientStateChangedEvent and future event types are + // dispatched to roles that subscribe to them via other + // mechanisms (e.g., the event bus directly). The role + // dispatcher only handles lifecycle events. + } + } +} + +// subscribeInternal creates a subscription while the caller already +// holds g.mu.Lock. This avoids the deadlock that would occur if we +// called the public Subscribe (which also takes Lock). +func (g *Group) subscribeInternal() (<-chan Event, func()) { + if g.closed { + ch := make(chan Event) + close(ch) + return ch, func() {} + } + + id := g.nextSub + g.nextSub++ + ch := make(chan Event, 32) + g.subs[id] = ch + + return ch, func() { + g.mu.Lock() + defer g.mu.Unlock() + if existing, ok := g.subs[id]; ok { + delete(g.subs, id) + close(existing) + } + } +} diff --git a/third_party/sendspin-go/pkg/sendspin/group_role_test.go b/third_party/sendspin-go/pkg/sendspin/group_role_test.go new file mode 100644 index 0000000..7b2eb26 --- /dev/null +++ b/third_party/sendspin-go/pkg/sendspin/group_role_test.go @@ -0,0 +1,147 @@ +// ABOUTME: Tests for GroupRole interface and role dispatch on Group +// ABOUTME: Verifies RegisterRole, event dispatch, and message routing +package sendspin + +import ( + "encoding/json" + "sync" + "testing" + "time" +) + +// testRole is a minimal GroupRole implementation for testing. +type testRole struct { + mu sync.Mutex + role string + joined []*ServerClient + left []string + messages []json.RawMessage +} + +func (r *testRole) Role() string { return r.role } + +func (r *testRole) OnClientJoin(c *ServerClient) { + r.mu.Lock() + defer r.mu.Unlock() + r.joined = append(r.joined, c) +} + +func (r *testRole) OnClientLeave(id string, name string) { + r.mu.Lock() + defer r.mu.Unlock() + r.left = append(r.left, id) +} + +func (r *testRole) HandleMessage(c *ServerClient, payload json.RawMessage) error { + r.mu.Lock() + defer r.mu.Unlock() + r.messages = append(r.messages, payload) + return nil +} + +func TestGroup_RegisterRole(t *testing.T) { + g := NewGroup("test") + defer g.Close() + + role := &testRole{role: "controller"} + g.RegisterRole(role) + + got := g.GetRole("controller") + if got != role { + t.Errorf("GetRole returned %v, want registered role", got) + } + if g.GetRole("nonexistent") != nil { + t.Error("GetRole for unregistered role should return nil") + } +} + +func TestGroup_RoleDispatchOnJoinLeave(t *testing.T) { + g := NewGroup("test") + defer g.Close() + + role := &testRole{role: "player"} + g.RegisterRole(role) + + sc := &ServerClient{id: "c1", name: "Client 1", roles: []string{"player@v1"}} + g.addClient(sc) + + // Give the dispatcher goroutine time to process. + time.Sleep(50 * time.Millisecond) + + role.mu.Lock() + joinedCount := len(role.joined) + var joinedID string + if joinedCount > 0 { + joinedID = role.joined[0].ID() + } + role.mu.Unlock() + + if joinedCount != 1 || joinedID != "c1" { + t.Errorf("OnClientJoin: got %d calls, want 1 with id=c1", joinedCount) + } + + g.removeClient(sc) + time.Sleep(50 * time.Millisecond) + + role.mu.Lock() + leftCount := len(role.left) + var leftID string + if leftCount > 0 { + leftID = role.left[0] + } + role.mu.Unlock() + + if leftCount != 1 || leftID != "c1" { + t.Errorf("OnClientLeave: got %d calls, want 1 with id=c1", leftCount) + } +} + +func TestGroup_RouteMessage(t *testing.T) { + g := NewGroup("test") + defer g.Close() + + role := &testRole{role: "controller"} + g.RegisterRole(role) + + sc := &ServerClient{id: "c1"} + payload := json.RawMessage(`{"command":"next"}`) + + err := g.RouteMessage(sc, "controller", payload) + if err != nil { + t.Fatalf("RouteMessage: %v", err) + } + if len(role.messages) != 1 { + t.Fatalf("HandleMessage called %d times, want 1", len(role.messages)) + } +} + +func TestGroup_RouteMessageNoHandler(t *testing.T) { + g := NewGroup("test") + defer g.Close() + + err := g.RouteMessage(&ServerClient{id: "c1"}, "nonexistent", json.RawMessage(`{}`)) + if err == nil { + t.Error("RouteMessage to unregistered role should return error") + } +} + +// roleWithoutMessageHandler implements GroupRole but NOT MessageHandler. +type roleWithoutMessageHandler struct { + role string +} + +func (r *roleWithoutMessageHandler) Role() string { return r.role } +func (r *roleWithoutMessageHandler) OnClientJoin(c *ServerClient) {} +func (r *roleWithoutMessageHandler) OnClientLeave(id string, name string) {} + +func TestGroup_RouteMessageRoleWithoutHandler(t *testing.T) { + g := NewGroup("test") + defer g.Close() + + g.RegisterRole(&roleWithoutMessageHandler{role: "metadata"}) + + err := g.RouteMessage(&ServerClient{id: "c1"}, "metadata", json.RawMessage(`{}`)) + if err == nil { + t.Error("RouteMessage to role without MessageHandler should return error") + } +} diff --git a/third_party/sendspin-go/pkg/sendspin/group_test.go b/third_party/sendspin-go/pkg/sendspin/group_test.go new file mode 100644 index 0000000..5318e50 --- /dev/null +++ b/third_party/sendspin-go/pkg/sendspin/group_test.go @@ -0,0 +1,327 @@ +// ABOUTME: Tests for the Group event bus plumbing +// ABOUTME: Fan-out, unsubscribe, and slow-subscriber drop behavior +package sendspin + +import ( + "sync" + "testing" + "time" +) + +// TestGroup_PublishSubscribe confirms the basic contract: an event +// published after Subscribe is delivered to the subscriber's channel. +func TestGroup_PublishSubscribe(t *testing.T) { + g := NewGroup("test-group") + defer g.Close() + + events, unsubscribe := g.Subscribe() + defer unsubscribe() + + g.publish(ClientJoinedEvent{Client: &ServerClient{id: "c1"}}) + + select { + case evt := <-events: + joined, ok := evt.(ClientJoinedEvent) + if !ok { + t.Fatalf("got %T, want ClientJoinedEvent", evt) + } + if joined.Client.ID() != "c1" { + t.Errorf("Client.ID() = %q, want %q", joined.Client.ID(), "c1") + } + case <-time.After(100 * time.Millisecond): + t.Fatal("timed out waiting for published event") + } +} + +// TestGroup_FanOut confirms one publish reaches every active subscriber. +func TestGroup_FanOut(t *testing.T) { + g := NewGroup("test-group") + defer g.Close() + + const subscribers = 3 + chans := make([]<-chan Event, subscribers) + for i := 0; i < subscribers; i++ { + ch, unsub := g.Subscribe() + defer unsub() + chans[i] = ch + } + + g.publish(ClientLeftEvent{ClientID: "c-gone", ClientName: "Gone Client"}) + + var wg sync.WaitGroup + for i, ch := range chans { + wg.Add(1) + go func(idx int, c <-chan Event) { + defer wg.Done() + select { + case evt := <-c: + if _, ok := evt.(ClientLeftEvent); !ok { + t.Errorf("subscriber %d got %T, want ClientLeftEvent", idx, evt) + } + case <-time.After(100 * time.Millisecond): + t.Errorf("subscriber %d timed out", idx) + } + }(i, ch) + } + wg.Wait() +} + +// TestGroup_Unsubscribe confirms that after calling the unsubscribe func, +// further publishes do NOT reach the channel. +func TestGroup_Unsubscribe(t *testing.T) { + g := NewGroup("test-group") + defer g.Close() + + events, unsubscribe := g.Subscribe() + + g.publish(ClientJoinedEvent{Client: &ServerClient{id: "c1"}}) + <-events // drain the first event + + unsubscribe() + + // Publish a second event — the unsubscribed channel should not receive it. + g.publish(ClientJoinedEvent{Client: &ServerClient{id: "c2"}}) + + select { + case evt, ok := <-events: + if ok { + t.Errorf("received event %v after unsubscribe", evt) + } + // Channel closed — also acceptable. + case <-time.After(50 * time.Millisecond): + // No event arrived within the window — correct behavior. + } +} + +// TestGroup_SlowSubscriberDoesNotBlockPublisher pins the non-blocking +// fan-out contract: a subscriber that never reads must not stall events +// for other subscribers or the publisher itself. +func TestGroup_SlowSubscriberDoesNotBlockPublisher(t *testing.T) { + g := NewGroup("test-group") + defer g.Close() + + // slow subscriber — we intentionally never read from this. + _, _ = g.Subscribe() + + fast, unsubscribeFast := g.Subscribe() + defer unsubscribeFast() + + // Overflow the slow subscriber's buffer. + for i := 0; i < 100; i++ { + g.publish(ClientJoinedEvent{Client: &ServerClient{id: "flood"}}) + } + + // The fast subscriber should still have received events. + received := 0 + timeout := time.After(200 * time.Millisecond) +loop: + for { + select { + case <-fast: + received++ + if received >= 32 { + break loop + } + case <-timeout: + break loop + } + } + if received == 0 { + t.Error("fast subscriber received zero events while slow one blocked") + } +} + +// TestGroup_IDReturnsConstructorValue just pins the GroupID accessor. +func TestGroup_IDReturnsConstructorValue(t *testing.T) { + g := NewGroup("my-group-id") + defer g.Close() + if got := g.ID(); got != "my-group-id" { + t.Errorf("ID() = %q, want %q", got, "my-group-id") + } +} + +// TestGroup_ConcurrentSubscribeClose spawns many Subscribe callers +// against a concurrent Close and asserts that nothing panics and every +// returned channel is either pre-closed or closes promptly. Guards the +// lock discipline against future refactors that could regress the +// Subscribe-vs-Close ordering. +func TestGroup_ConcurrentSubscribeClose(t *testing.T) { + const subscribers = 50 + g := NewGroup("race-bait") + + var wg sync.WaitGroup + subsReady := make(chan struct{}) + + for i := 0; i < subscribers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + <-subsReady + ch, unsub := g.Subscribe() + defer unsub() + + // Drain until the channel closes. If Close races with our + // Subscribe we should either get a pre-closed channel + // immediately or see it close after Close completes. + deadline := time.After(500 * time.Millisecond) + for { + select { + case _, ok := <-ch: + if !ok { + return + } + case <-deadline: + t.Error("channel never closed") + return + } + } + }() + } + + close(subsReady) + time.Sleep(5 * time.Millisecond) // let some Subscribes land + g.Close() + + wg.Wait() +} + +// TestGroup_ConcurrentPublishClose races publishes against a close and +// asserts no panic. A send on a closed channel would surface as a panic +// recovered by the test runner. +func TestGroup_ConcurrentPublishClose(t *testing.T) { + g := NewGroup("race-bait") + + // Pre-subscribe so there's a channel to fan out to. + _, unsub := g.Subscribe() + defer unsub() + + done := make(chan struct{}) + go func() { + for i := 0; i < 1000; i++ { + g.publish(ClientJoinedEvent{Client: &ServerClient{id: "flood"}}) + } + close(done) + }() + + time.Sleep(2 * time.Millisecond) // let some publishes land + g.Close() + <-done +} + +// TestGroup_SetPlaybackState_PublishesOnChange confirms that +// SetPlaybackState publishes a GroupPlaybackStateChangedEvent only on +// actual transitions, and is a silent no-op on same-state writes. The +// group's default state is "playing" (per NewGroup), so calling +// SetPlaybackState("playing") first must produce no event; transitioning +// to "stopped" must produce exactly one event with OldState="playing". +func TestGroup_SetPlaybackState_PublishesOnChange(t *testing.T) { + g := NewGroup("test-group") + defer g.Close() + + // Subscribe BEFORE any state writes so we observe every emitted event. + events, unsubscribe := g.Subscribe() + defer unsubscribe() + + // Same-state write: must not publish (default is "playing"). + g.SetPlaybackState("playing") + + select { + case evt := <-events: + t.Fatalf("same-state SetPlaybackState published unexpected event: %T %+v", evt, evt) + case <-time.After(30 * time.Millisecond): + // Expected: no event. + } + + // Real transition: must publish exactly one event. + g.SetPlaybackState("stopped") + + select { + case evt := <-events: + ps, ok := evt.(GroupPlaybackStateChangedEvent) + if !ok { + t.Fatalf("got %T, want GroupPlaybackStateChangedEvent", evt) + } + t.Logf("event=%+v", ps) + if ps.OldState != "playing" || ps.NewState != "stopped" { + t.Errorf("event = %+v, want {OldState: playing, NewState: stopped}", ps) + } + case <-time.After(100 * time.Millisecond): + t.Fatal("timed out waiting for GroupPlaybackStateChangedEvent") + } + + // Same-state write again: must not publish. + g.SetPlaybackState("stopped") + + select { + case evt := <-events: + t.Fatalf("repeat same-state SetPlaybackState published unexpected event: %T %+v", evt, evt) + case <-time.After(30 * time.Millisecond): + // Expected. + } +} + +// TestGroup_SetPlaybackState_OldStateInEvent pins that successive +// transitions carry the prior playback state in OldState — not the empty +// string, not the first-ever value. This guards against future refactors +// that might forget to capture oldState before mutation. +func TestGroup_SetPlaybackState_OldStateInEvent(t *testing.T) { + g := NewGroup("test-group") + defer g.Close() + + events, unsubscribe := g.Subscribe() + defer unsubscribe() + + g.SetPlaybackState("paused") + g.SetPlaybackState("playing") + + // First event: playing → paused + select { + case evt := <-events: + ps, ok := evt.(GroupPlaybackStateChangedEvent) + if !ok { + t.Fatalf("got %T, want GroupPlaybackStateChangedEvent", evt) + } + t.Logf("event[0]=%+v", ps) + if ps.OldState != "playing" || ps.NewState != "paused" { + t.Errorf("event[0] = %+v, want {playing, paused}", ps) + } + case <-time.After(100 * time.Millisecond): + t.Fatal("timed out on first event") + } + + // Second event: paused → playing + select { + case evt := <-events: + ps, ok := evt.(GroupPlaybackStateChangedEvent) + if !ok { + t.Fatalf("got %T, want GroupPlaybackStateChangedEvent", evt) + } + t.Logf("event[1]=%+v", ps) + if ps.OldState != "paused" || ps.NewState != "playing" { + t.Errorf("event[1] = %+v, want {paused, playing}", ps) + } + case <-time.After(100 * time.Millisecond): + t.Fatal("timed out on second event") + } +} + +// TestGroup_AddClientSendsGroupUpdate confirms that addClient sends +// a group/update message to the joining client before publishing +// ClientJoinedEvent. +func TestGroup_AddClientSendsGroupUpdate(t *testing.T) { + g := NewGroup("group-123") + defer g.Close() + + sc := &ServerClient{ + id: "c1", + sendChan: make(chan interface{}, 10), + } + g.addClient(sc) + + select { + case msg := <-sc.sendChan: + _ = msg // Verifies group/update was sent + default: + t.Fatal("addClient did not send group/update") + } +} diff --git a/third_party/sendspin-go/pkg/sendspin/player.go b/third_party/sendspin-go/pkg/sendspin/player.go new file mode 100644 index 0000000..6e5f399 --- /dev/null +++ b/third_party/sendspin-go/pkg/sendspin/player.go @@ -0,0 +1,583 @@ +// ABOUTME: High-level Player API for Sendspin streaming +// ABOUTME: Composes Receiver + audio output with optional ProcessCallback +package sendspin + +import ( + "context" + "fmt" + "log" + "math/rand" + "time" + + "github.com/Sendspin/sendspin-go/pkg/audio" + "github.com/Sendspin/sendspin-go/pkg/audio/decode" + "github.com/Sendspin/sendspin-go/pkg/audio/output" + "github.com/Sendspin/sendspin-go/pkg/protocol" + "github.com/Sendspin/sendspin-go/pkg/sync" +) + +// ReconnectConfig controls automatic reconnect behavior after the protocol +// connection drops. When Enabled is false (the zero value), Player behaves +// as a one-shot: a lost connection stays lost. +type ReconnectConfig struct { + Enabled bool + InitialDelay time.Duration // default 500ms + MaxDelay time.Duration // default 30s + Multiplier float64 // default 2.0 + MaxAttempts int // 0 = infinite (default) + + // Rediscover is an optional callback invoked before each reconnect + // attempt. Returning a non-empty address overrides the configured + // ServerAddr for that attempt. Use this to re-run mDNS discovery when + // the server may have moved. Errors are logged and the attempt falls + // back to the last known address. + Rediscover func(ctx context.Context) (string, error) +} + +// PlayerConfig holds player configuration +type PlayerConfig struct { + // ServerAddr is the server address (host:port) + ServerAddr string + + PlayerName string + + // Volume is the initial volume (0-100) + Volume int + + // BufferMs is the playback buffer size in milliseconds (default: 500) + BufferMs int + + // StaticDelayMs shifts every scheduled play time forward by this many + // milliseconds. Used to compensate for hardware that introduces a fixed + // downstream latency (Bluetooth sinks, AVRs with DSP, some USB DACs). + // Default 0 means no shift. + StaticDelayMs int + + // PreferredCodec reorders the advertised format list so the server + // picks this codec first. Values: "pcm" (default), "opus", "flac". + PreferredCodec string + + // BufferCapacity is the buffer_capacity (bytes) advertised to the + // server in client/hello. The server uses this to pace how far ahead + // it sends audio. Default: 1048576 (1MB). + BufferCapacity int + + // ClientID is the already-resolved client_id to advertise in client/hello. + // Required — callers should compute this once at startup (typically via + // ResolveClientID) and reuse the same value across reconnects. + ClientID string + + // AudioDevice selects a specific playback device by name (as reported by + // output.ListPlaybackDevices). Empty = let miniaudio pick the default. + // Open fails loudly if a non-empty name doesn't match an available device. + AudioDevice string + + // MaxSampleRate caps the highest SampleRate advertised to the server. + // 0 = auto-probe the AudioDevice for its native ceiling on first Connect. + // Setting either MaxSampleRate or MaxBitDepth to a non-zero value disables + // auto-probe entirely (replace semantics — explicit user choice wins, even + // if it raises the cap above what the probe would have found). Use the + // override when the auto-probe is wrong, as on Pi3 where ALSA reports the + // bcm2835 onboard headphones accept 192k/24 but the hardware can't + // actually drain it. + MaxSampleRate int + // MaxBitDepth caps the highest BitDepth advertised to the server. + // 0 = auto-probe. See MaxSampleRate for override semantics. + MaxBitDepth int + + DeviceInfo DeviceInfo + + OnMetadata func(Metadata) + + OnStateChange func(PlayerState) + + OnError func(error) + + // Output overrides the default audio output backend. + // When nil, a malgo-backed output is created on stream start. + Output output.Output + + // DecoderFactory overrides the default decoder selection. + // When nil, the default codec switch (PCM, Opus, FLAC) is used. + DecoderFactory func(audio.Format) (decode.Decoder, error) + + // ProcessCallback is called with decoded samples before they are written to output. + // Must not block. Runs on the audio consumption goroutine. + ProcessCallback func([]int32) + + // Reconnect controls automatic reconnection behavior when the protocol + // connection drops. Disabled by default. + Reconnect ReconnectConfig +} + +type DeviceInfo struct { + ProductName string + Manufacturer string + SoftwareVersion string +} + +type Metadata struct { + Title string + Artist string + Album string + AlbumArtist string + ArtworkURL string + Track int + Year int + Duration int // seconds +} + +type PlayerState struct { + State string // "idle", "playing", "paused" + Volume int + Muted bool + Codec string + SampleRate int + Channels int + BitDepth int + Connected bool +} + +type PlayerStats struct { + Received int64 + Played int64 + Dropped int64 + BufferDepth int // milliseconds + SyncRTT int64 + SyncQuality sync.Quality +} + +// Player provides high-level audio playback from Sendspin servers. +// It composes a Receiver (connect/sync/decode/schedule) with an audio output backend. +type Player struct { + config PlayerConfig + receiver *Receiver + output output.Output + state PlayerState + ctx context.Context + cancel context.CancelFunc + capsResolved bool // probe runs once at first Connect; reconnects reuse the cached caps +} + +func NewPlayer(config PlayerConfig) (*Player, error) { + if config.Volume == 0 { + config.Volume = 100 + } + if config.BufferMs == 0 { + config.BufferMs = 500 + } + if config.Reconnect.Enabled { + if config.Reconnect.InitialDelay <= 0 { + config.Reconnect.InitialDelay = 500 * time.Millisecond + } + if config.Reconnect.MaxDelay <= 0 { + config.Reconnect.MaxDelay = 30 * time.Second + } + if config.Reconnect.Multiplier <= 1.0 { + config.Reconnect.Multiplier = 2.0 + } + } + + ctx, cancel := context.WithCancel(context.Background()) + + return &Player{ + config: config, + output: config.Output, + ctx: ctx, + cancel: cancel, + state: PlayerState{ + State: "idle", + Volume: config.Volume, + Muted: false, + Connected: false, + }, + }, nil +} + +func (p *Player) Connect() error { + recv, err := p.buildReceiver(p.config.ServerAddr) + if err != nil { + return err + } + if err := recv.Connect(); err != nil { + return err + } + + p.receiver = recv + p.state.Connected = true + p.notifyStateChange() + + go p.consumeAudio(recv) + + if p.config.Reconnect.Enabled { + go p.runReconnectLoop(recv) + } + + return nil +} + +func (p *Player) buildReceiver(addr string) (*Receiver, error) { + p.ensureCapsResolved() + return NewReceiver(ReceiverConfig{ + ServerAddr: addr, + PlayerName: p.config.PlayerName, + BufferMs: p.config.BufferMs, + StaticDelayMs: p.config.StaticDelayMs, + PreferredCodec: p.config.PreferredCodec, + BufferCapacity: p.config.BufferCapacity, + MaxSampleRate: p.config.MaxSampleRate, + MaxBitDepth: p.config.MaxBitDepth, + ClientID: p.config.ClientID, + DeviceInfo: p.config.DeviceInfo, + DecoderFactory: p.config.DecoderFactory, + OnMetadata: p.config.OnMetadata, + OnStreamStart: p.onStreamStart, + OnStreamEnd: p.onStreamEnd, + OnError: p.config.OnError, + OnControl: p.onControl, + }) +} + +func (p *Player) onControl(cmd protocol.PlayerCommand) { + switch cmd.Command { + case "volume": + _ = p.SetVolume(cmd.Volume) + case "mute": + _ = p.Mute(cmd.Mute) + } +} + +// ensureCapsResolved decides MaxSampleRate / MaxBitDepth on first call and +// caches the decision so subsequent reconnects reuse the same caps without +// re-probing miniaudio. +// +// Replace semantics: any explicit non-zero override on either field skips +// the probe entirely, so users who want to raise the cap above the device's +// reported ceiling can. Probe failures are logged and treated as "no cap" — +// we'd rather advertise too much (and let the device-stall path complain) +// than refuse to start when the malgo backend is unavailable (e.g. CI). +// +// When config.Output is non-nil, the caller has substituted their own output +// (test doubles, custom backends), and probing the malgo default device +// wouldn't tell us anything useful — skip in that case too. +func (p *Player) ensureCapsResolved() { + if p.capsResolved { + return + } + p.capsResolved = true + + if p.config.MaxSampleRate != 0 || p.config.MaxBitDepth != 0 { + log.Printf("Output capability cap: %d Hz / %d-bit (source: config)", + p.config.MaxSampleRate, p.config.MaxBitDepth) + return + } + if p.config.Output != nil { + return + } + + rate, depth, err := output.QueryDeviceCapabilities(p.config.AudioDevice) + if err != nil { + log.Printf("Output capability probe failed (%v); advertising full format list", err) + return + } + if rate == 0 && depth == 0 { + log.Printf("Output capability probe returned no native formats; advertising full format list") + return + } + p.config.MaxSampleRate = rate + p.config.MaxBitDepth = depth + log.Printf("Output capability cap: %d Hz / %d-bit (source: probe)", rate, depth) +} + +// runReconnectLoop supervises the active receiver and rebuilds it with +// exponential backoff whenever its Done channel closes. Exits when the +// Player context is cancelled. +func (p *Player) runReconnectLoop(initial *Receiver) { + current := initial + for { + select { + case <-p.ctx.Done(): + return + case <-current.Done(): + } + + // Connection lost. Enter reconnecting state and back off. + select { + case <-p.ctx.Done(): + return + default: + } + + p.state.Connected = false + p.state.State = "reconnecting" + p.notifyStateChange() + + next, ok := p.reconnectWithBackoff() + if !ok { + return + } + current = next + + p.receiver = current + p.state.Connected = true + p.notifyStateChange() + + go p.consumeAudio(current) + } +} + +func (p *Player) reconnectWithBackoff() (*Receiver, bool) { + cfg := p.config.Reconnect + delay := cfg.InitialDelay + attempt := 0 + + for { + attempt++ + if cfg.MaxAttempts > 0 && attempt > cfg.MaxAttempts { + p.notifyError(fmt.Errorf("reconnect: gave up after %d attempts", cfg.MaxAttempts)) + return nil, false + } + + // Jittered sleep (±20%). + jittered := jitter(delay, 0.2) + log.Printf("Reconnect attempt %d in %v", attempt, jittered) + select { + case <-p.ctx.Done(): + return nil, false + case <-time.After(jittered): + } + + addr := p.config.ServerAddr + if cfg.Rediscover != nil { + discovered, err := cfg.Rediscover(p.ctx) + if err != nil { + log.Printf("Reconnect: rediscover failed: %v (using last known addr %s)", err, addr) + } else if discovered != "" { + addr = discovered + } + } + + recv, err := p.buildReceiver(addr) + if err == nil { + if err = recv.Connect(); err == nil { + log.Printf("Reconnect: connected to %s on attempt %d", addr, attempt) + return recv, true + } + } + log.Printf("Reconnect attempt %d to %s failed: %v", attempt, addr, err) + + delay = time.Duration(float64(delay) * cfg.Multiplier) + if delay > cfg.MaxDelay { + delay = cfg.MaxDelay + } + } +} + +func jitter(d time.Duration, frac float64) time.Duration { + if d <= 0 { + return d + } + delta := (rand.Float64()*2 - 1) * frac + return time.Duration(float64(d) * (1 + delta)) +} + +func (p *Player) onStreamStart(format audio.Format) { + if p.output == nil { + p.output = output.NewMalgo(p.config.AudioDevice) + } + + if err := p.output.Open(format.SampleRate, format.Channels, format.BitDepth); err != nil { + p.notifyError(fmt.Errorf("failed to initialize output: %w", err)) + return + } + + p.output.SetVolume(p.state.Volume) + p.output.SetMuted(p.state.Muted) + + p.state.Codec = format.Codec + p.state.SampleRate = format.SampleRate + p.state.Channels = format.Channels + p.state.BitDepth = format.BitDepth + p.state.State = "playing" + p.notifyStateChange() +} + +func (p *Player) onStreamEnd() { + p.state.State = "idle" + p.notifyStateChange() +} + +func (p *Player) consumeAudio(recv *Receiver) { + for { + select { + case buf, ok := <-recv.Output(): + if !ok { + return + } + + if p.config.ProcessCallback != nil { + p.config.ProcessCallback(buf.Samples) + } + + if p.output != nil { + if err := p.output.Write(buf.Samples); err != nil { + p.notifyError(fmt.Errorf("playback error: %w", err)) + } + } + + case <-p.ctx.Done(): + return + } + } +} + +func (p *Player) Play() error { + if !p.state.Connected { + return fmt.Errorf("not connected") + } + p.state.State = "playing" + p.notifyStateChange() + return p.sendState() +} + +func (p *Player) Pause() error { + if !p.state.Connected { + return fmt.Errorf("not connected") + } + p.state.State = "paused" + p.notifyStateChange() + return p.sendState() +} + +func (p *Player) Stop() error { + if !p.state.Connected { + return fmt.Errorf("not connected") + } + p.state.State = "idle" + p.notifyStateChange() + return p.sendState() +} + +// SetVolume sets the volume (0-100) +func (p *Player) SetVolume(volume int) error { + if volume < 0 { + volume = 0 + } + if volume > 100 { + volume = 100 + } + p.state.Volume = volume + + if p.output != nil { + p.output.SetVolume(volume) + } + + if p.receiver != nil && p.state.Connected { + p.sendState() + } + + p.notifyStateChange() + return nil +} + +func (p *Player) Mute(muted bool) error { + p.state.Muted = muted + + if p.output != nil { + p.output.SetMuted(muted) + } + + if p.receiver != nil && p.state.Connected { + p.sendState() + } + + p.notifyStateChange() + return nil +} + +func (p *Player) Status() PlayerState { + return p.state +} + +func (p *Player) Stats() PlayerStats { + stats := PlayerStats{} + + if p.receiver != nil { + rs := p.receiver.Stats() + stats.Received = rs.Received + stats.Played = rs.Played + stats.Dropped = rs.Dropped + stats.BufferDepth = rs.BufferDepth + stats.SyncRTT = rs.SyncRTT + stats.SyncQuality = rs.SyncQuality + } + + return stats +} + +func (p *Player) Close() error { + p.cancel() + + if p.receiver != nil { + p.receiver.Close() + } + + if p.output != nil { + p.output.Close() + } + + p.state.Connected = false + p.state.State = "idle" + p.notifyStateChange() + + return nil +} + +// SendCommand sends a controller command to the server (e.g., "play", +// "pause", "next", "previous"). This is how a player requests playback +// control — the server decides whether to act on it. +func (p *Player) SendCommand(command string) error { + if p.receiver == nil || p.receiver.client == nil { + return fmt.Errorf("not connected") + } + payload := map[string]interface{}{ + "controller": map[string]interface{}{ + "command": command, + }, + } + return p.receiver.client.Send("client/command", payload) +} + +func (p *Player) sendState() error { + if p.receiver == nil || p.receiver.client == nil { + return nil + } + return p.receiver.client.SendState(protocol.PlayerState{ + State: "synchronized", + Volume: p.state.Volume, + Muted: p.state.Muted, + }) +} + +func (p *Player) notifyStateChange() { + if p.config.OnStateChange != nil { + p.config.OnStateChange(p.state) + } +} + +func (p *Player) notifyError(err error) { + if p.config.OnError != nil { + p.config.OnError(err) + } else { + log.Printf("Player error: %v", err) + } +} + +func containsRole(roles []string, role string) bool { + for _, r := range roles { + if r == role { + return true + } + } + return false +} diff --git a/third_party/sendspin-go/pkg/sendspin/player_test.go b/third_party/sendspin-go/pkg/sendspin/player_test.go new file mode 100644 index 0000000..c2b67f2 --- /dev/null +++ b/third_party/sendspin-go/pkg/sendspin/player_test.go @@ -0,0 +1,259 @@ +// ABOUTME: Tests for refactored Player composing Receiver +// ABOUTME: Verifies backward-compatible API and new ProcessCallback +package sendspin + +import ( + "context" + "testing" + "time" +) + +func TestNewPlayer_Defaults(t *testing.T) { + player, err := NewPlayer(PlayerConfig{ + ServerAddr: "localhost:8927", + PlayerName: "Test Player", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if player == nil { + t.Fatal("expected non-nil player") + } + if player.receiver != nil { + t.Error("receiver should be nil before Connect") + } +} + +func TestNewPlayer_ProcessCallbackStored(t *testing.T) { + player, _ := NewPlayer(PlayerConfig{ + ServerAddr: "localhost:8927", + PlayerName: "Test Player", + ProcessCallback: func(samples []int32) {}, + }) + if player.config.ProcessCallback == nil { + t.Error("expected ProcessCallback to be stored in config") + } +} + +// TestNewPlayer_DeviceInfoPassesThrough guards #48: --manufacturer and +// --product-name CLI flags write into PlayerConfig.DeviceInfo, and must +// survive the trip into the underlying Receiver without being replaced +// by the library defaults. +func TestNewPlayer_DeviceInfoPassesThrough(t *testing.T) { + custom := DeviceInfo{ + ProductName: "Custom Product", + Manufacturer: "Custom Mfg", + SoftwareVersion: "9.9.9", + } + player, err := NewPlayer(PlayerConfig{ + ServerAddr: "localhost:8927", + PlayerName: "Test", + DeviceInfo: custom, + }) + if err != nil { + t.Fatalf("NewPlayer: %v", err) + } + if player.config.DeviceInfo != custom { + t.Errorf("config.DeviceInfo = %+v, want %+v", player.config.DeviceInfo, custom) + } +} + +// TestNewPlayer_StaticDelayStored guards #47: PlayerConfig.StaticDelayMs +// must be preserved on the Player so Connect can plumb it into Receiver +// and from there into Scheduler. +func TestNewPlayer_StaticDelayStored(t *testing.T) { + player, err := NewPlayer(PlayerConfig{ + ServerAddr: "localhost:8927", + PlayerName: "Test", + StaticDelayMs: 250, + }) + if err != nil { + t.Fatalf("NewPlayer: %v", err) + } + if player.config.StaticDelayMs != 250 { + t.Errorf("config.StaticDelayMs = %d, want 250", player.config.StaticDelayMs) + } +} + +// TestReceiver_StaticDelayDefaultZero sanity-checks that a ReceiverConfig +// without StaticDelayMs set produces a scheduler with zero offset. This is +// the test that would have flagged the new field if a future refactor +// stopped plumbing it through. Uses a short connect attempt + teardown +// because we don't want to actually dial anything. +func TestReceiver_StaticDelayDefaultZero(t *testing.T) { + recv, err := NewReceiver(ReceiverConfig{ + ServerAddr: "localhost:0", + PlayerName: "Test", + }) + if err != nil { + t.Fatalf("NewReceiver: %v", err) + } + defer recv.Close() + if recv.config.StaticDelayMs != 0 { + t.Errorf("default StaticDelayMs = %d, want 0", recv.config.StaticDelayMs) + } +} + +// TestNewPlayer_ReconnectDefaultsApplied guards the reconnect backoff +// defaults (#38). When the caller enables reconnect but leaves timing +// fields zero, NewPlayer must fill them with the documented defaults so +// an accidentally-zero delay never spin-loops. +func TestNewPlayer_ReconnectDefaultsApplied(t *testing.T) { + player, err := NewPlayer(PlayerConfig{ + ServerAddr: "localhost:8927", + PlayerName: "Test", + Reconnect: ReconnectConfig{Enabled: true}, + }) + if err != nil { + t.Fatalf("NewPlayer: %v", err) + } + rc := player.config.Reconnect + if rc.InitialDelay != 500*time.Millisecond { + t.Errorf("InitialDelay = %v, want 500ms", rc.InitialDelay) + } + if rc.MaxDelay != 30*time.Second { + t.Errorf("MaxDelay = %v, want 30s", rc.MaxDelay) + } + if rc.Multiplier != 2.0 { + t.Errorf("Multiplier = %v, want 2.0", rc.Multiplier) + } + if rc.MaxAttempts != 0 { + t.Errorf("MaxAttempts = %d, want 0 (infinite)", rc.MaxAttempts) + } +} + +// TestNewPlayer_ReconnectDefaultsSkippedWhenDisabled makes sure we don't +// silently turn reconnect on. If Enabled is false we leave the zero values +// alone — there is no supervisor goroutine to read them anyway. +func TestNewPlayer_ReconnectDefaultsSkippedWhenDisabled(t *testing.T) { + player, _ := NewPlayer(PlayerConfig{ + ServerAddr: "localhost:8927", + PlayerName: "Test", + }) + if player.config.Reconnect.Enabled { + t.Error("Reconnect.Enabled should default to false") + } + if player.config.Reconnect.InitialDelay != 0 { + t.Error("InitialDelay should not be populated when Reconnect.Enabled is false") + } +} + +// TestNewPlayer_ReconnectRediscoverCallbackStored confirms the closure +// survives into player.config so the reconnect supervisor can call it. +func TestNewPlayer_ReconnectRediscoverCallbackStored(t *testing.T) { + called := false + player, _ := NewPlayer(PlayerConfig{ + ServerAddr: "localhost:8927", + PlayerName: "Test", + Reconnect: ReconnectConfig{ + Enabled: true, + Rediscover: func(ctx context.Context) (string, error) { + called = true + return "other:1234", nil + }, + }, + }) + if player.config.Reconnect.Rediscover == nil { + t.Fatal("Rediscover callback not stored") + } + addr, err := player.config.Reconnect.Rediscover(context.Background()) + if err != nil || addr != "other:1234" || !called { + t.Errorf("callback not invoked correctly: addr=%q err=%v called=%v", addr, err, called) + } +} + +// TestJitter stays inside the ±frac band. With frac=0.2 and a 1s delay, +// the result must always fall within [800ms, 1200ms]. +func TestJitter(t *testing.T) { + base := 1 * time.Second + for i := 0; i < 100; i++ { + got := jitter(base, 0.2) + if got < 800*time.Millisecond || got > 1200*time.Millisecond { + t.Errorf("jitter(%v, 0.2) = %v, outside ±20%% band", base, got) + } + } + if jitter(0, 0.2) != 0 { + t.Error("jitter(0, _) should return 0") + } +} + +func TestPlayer_StatusBeforeConnect(t *testing.T) { + player, _ := NewPlayer(PlayerConfig{ + ServerAddr: "localhost:8927", + PlayerName: "Test Player", + Volume: 80, + }) + + status := player.Status() + if status.Volume != 80 { + t.Errorf("expected volume 80, got %d", status.Volume) + } + if status.Connected { + t.Error("expected not connected before Connect()") + } + if status.State != "idle" { + t.Errorf("expected state idle, got %s", status.State) + } +} + +// TestPlayer_EnsureCapsResolved_ExplicitOverrideSkipsProbe asserts replace +// semantics: if the user has set either MaxSampleRate or MaxBitDepth, +// ensureCapsResolved must NOT overwrite the other field via probe. +// Explicit user choice wins entirely, even when only one of the two +// fields is non-zero. +func TestPlayer_EnsureCapsResolved_ExplicitOverrideSkipsProbe(t *testing.T) { + tests := []struct { + name string + rate int + depth int + wantRate int + wantDepth int + }{ + {"both set", 48000, 16, 48000, 16}, + {"rate only", 96000, 0, 96000, 0}, + {"depth only", 0, 16, 0, 16}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + player, _ := NewPlayer(PlayerConfig{ + ServerAddr: "localhost:8927", + PlayerName: "Test", + MaxSampleRate: tt.rate, + MaxBitDepth: tt.depth, + }) + player.ensureCapsResolved() + if player.config.MaxSampleRate != tt.wantRate { + t.Errorf("MaxSampleRate = %d, want %d (probe must not run)", + player.config.MaxSampleRate, tt.wantRate) + } + if player.config.MaxBitDepth != tt.wantDepth { + t.Errorf("MaxBitDepth = %d, want %d (probe must not run)", + player.config.MaxBitDepth, tt.wantDepth) + } + }) + } +} + +// TestPlayer_EnsureCapsResolved_RunsOnce guards reconnect behavior: the +// caps are probed at most once. The cached flag is set even on probe-skip +// paths so subsequent reconnects don't re-probe miniaudio either. +func TestPlayer_EnsureCapsResolved_RunsOnce(t *testing.T) { + player, _ := NewPlayer(PlayerConfig{ + ServerAddr: "localhost:8927", + PlayerName: "Test", + MaxSampleRate: 48000, // user-set, so no probe + }) + player.ensureCapsResolved() + if !player.capsResolved { + t.Error("capsResolved should be true after first call") + } + // Mutating MaxSampleRate after first call must not be re-resolved by a + // second invocation — proves the early return on capsResolved fires. + player.config.MaxSampleRate = 99999 + player.ensureCapsResolved() + if player.config.MaxSampleRate != 99999 { + t.Errorf("second call should be no-op; got MaxSampleRate=%d", + player.config.MaxSampleRate) + } +} diff --git a/third_party/sendspin-go/pkg/sendspin/receiver.go b/third_party/sendspin-go/pkg/sendspin/receiver.go new file mode 100644 index 0000000..faf5c78 --- /dev/null +++ b/third_party/sendspin-go/pkg/sendspin/receiver.go @@ -0,0 +1,823 @@ +// ABOUTME: Receiver handles connection, sync, decode, and scheduling +// ABOUTME: Emits decoded audio.Buffer via Output() channel for consumers +package sendspin + +import ( + "context" + "encoding/base64" + "fmt" + "log" + "math" + "sort" + "strings" + stdsync "sync" + "time" + + "github.com/Sendspin/sendspin-go/pkg/audio" + "github.com/Sendspin/sendspin-go/pkg/audio/decode" + "github.com/Sendspin/sendspin-go/pkg/protocol" + "github.com/Sendspin/sendspin-go/pkg/sync" +) + +// Time-sync burst parameters. Mirrors sendspin-cpp's TimeBurst defaults and +// the upstream Sendspin/time-filter README "Recommended Usage" guidance: a +// short burst of NTP-style exchanges, each waiting for its reply, with the +// best (lowest RTT) sample fed to the filter once per burst. +const ( + timeSyncBurstSize = 8 + timeSyncBurstInterval = 10 * time.Second + timeSyncResponseTimeout = 500 * time.Millisecond +) + +// metadataApplyTickInterval is the cadence at which metadataApplyLoop wakes +// to drain pending updates whose server timestamp has elapsed. 100 ms is +// imperceptible for metadata display lag; do not shorten without a real +// reason. +const metadataApplyTickInterval = 100 * time.Millisecond + +type ReceiverConfig struct { + ServerAddr string + PlayerName string + BufferMs int + StaticDelayMs int // optional static latency compensation (ms) applied to every scheduled play time + PreferredCodec string // "pcm", "opus", or "flac" — reorders the advertised format list so the server picks this codec first + BufferCapacity int // buffer_capacity in bytes advertised to the server (default: 1048576 = 1MB) + // MaxSampleRate caps the highest SampleRate advertised to the server. + // 0 = no cap. Set this when the eventual audio output device cannot + // sustain higher rates (e.g. Pi3 onboard bcm2835 headphones can't + // actually drain 192k even though ALSA reports it accepts the format). + MaxSampleRate int + // MaxBitDepth caps the highest BitDepth advertised to the server. + // 0 = no cap. See MaxSampleRate for the motivating case. + MaxBitDepth int + // ClientID is the already-resolved client_id to advertise in client/hello. + // Required — callers should compute this once at startup (typically via + // ResolveClientID) and thread the same value through reconnects. + ClientID string + DeviceInfo DeviceInfo + DecoderFactory func(audio.Format) (decode.Decoder, error) + // OnMetadata is invoked after each server metadata update is merged + // onto the running snapshot. It may be called from either the + // server-state reader goroutine (immediate updates) or the metadata + // apply-loop goroutine (timestamp-deferred updates), and is invoked + // while an internal mutex is held — callbacks must not block on + // other Receiver methods. Implementations should serialize their + // own state if needed. + OnMetadata func(Metadata) + OnStreamStart func(audio.Format) + OnStreamEnd func() + OnError func(error) + // OnControl is invoked for each server/command (volume, mute) received + // from the server. Runs on a dedicated goroutine; must not block. + OnControl func(protocol.PlayerCommand) +} + +type ReceiverStats struct { + Received int64 + Played int64 + Dropped int64 + BufferDepth int + SyncRTT int64 + SyncQuality sync.Quality +} + +// Receiver handles connection, clock sync, decoding, and scheduling. +// It emits decoded, time-stamped audio buffers via the Output() channel. +type Receiver struct { + config ReceiverConfig + client *protocol.Client + clockSync *sync.ClockSync + scheduler *Scheduler + decoder decode.Decoder + format audio.Format + output chan audio.Buffer + ctx context.Context + cancel context.CancelFunc + schedulerCtx context.Context + schedulerCancel context.CancelFunc + serverAddr string + connected bool + + // Metadata merge state. mergedMetadata is the running snapshot fed to + // OnMetadata; pendingMetadata holds future-dated updates sorted by + // ascending Timestamp until clockNow() crosses each one. + metadataMu stdsync.Mutex + mergedMetadata Metadata + pendingMetadata []*protocol.MetadataState + + // clockNow returns "current server time in microseconds". Indirected + // from r.clockSync.ServerMicrosNow so tests can drive the + // timestamp-deferral path with a fake clock. + clockNow func() int64 +} + +// NewReceiver creates a new Receiver with the given configuration. +// ServerAddr is required; other fields have defaults. +func NewReceiver(config ReceiverConfig) (*Receiver, error) { + if config.ServerAddr == "" { + return nil, fmt.Errorf("ReceiverConfig.ServerAddr is required") + } + + if config.BufferMs == 0 { + config.BufferMs = 500 + } + if config.BufferCapacity == 0 { + config.BufferCapacity = 1048576 // 1MB default + } + if config.DeviceInfo.ProductName == "" { + config.DeviceInfo.ProductName = "Sendspin Player" + } + if config.DeviceInfo.Manufacturer == "" { + config.DeviceInfo.Manufacturer = "Sendspin" + } + if config.DeviceInfo.SoftwareVersion == "" { + config.DeviceInfo.SoftwareVersion = "1.3.0" + } + + ctx, cancel := context.WithCancel(context.Background()) + + clockSync := sync.NewClockSync() + + r := &Receiver{ + config: config, + clockSync: clockSync, + output: make(chan audio.Buffer, 10), + ctx: ctx, + cancel: cancel, + serverAddr: config.ServerAddr, + } + r.clockNow = r.clockSync.ServerMicrosNow + + return r, nil +} + +// Output returns the channel that emits decoded, time-stamped audio buffers. +func (r *Receiver) Output() <-chan audio.Buffer { + return r.output +} + +// ClockSync returns the clock synchronization instance used by this Receiver. +func (r *Receiver) ClockSync() *sync.ClockSync { + return r.clockSync +} + +// Done returns a channel that is closed when the receiver's context is +// cancelled — either by Close() or by watchConnection detecting a dropped +// protocol client. Callers can use this to implement reconnect loops. +func (r *Receiver) Done() <-chan struct{} { + return r.ctx.Done() +} + +// Stats returns current pipeline statistics from the scheduler and clock sync. +func (r *Receiver) Stats() ReceiverStats { + stats := ReceiverStats{} + + if r.scheduler != nil { + s := r.scheduler.Stats() + stats.Received = s.Received + stats.Played = s.Played + stats.Dropped = s.Dropped + stats.BufferDepth = r.scheduler.BufferDepth() + } + + if r.clockSync != nil { + rtt, quality := r.clockSync.GetStats() + stats.SyncRTT = rtt + stats.SyncQuality = quality + } + + return stats +} + +// Connect establishes a connection to the server, performs initial clock sync, +// and starts background goroutines for connection watching and clock sync. +func (r *Receiver) Connect() error { + if r.config.ClientID == "" { + return fmt.Errorf("ReceiverConfig.ClientID is required (resolve via sendspin.ResolveClientID)") + } + + supportedFormats := buildSupportedFormats(r.config.PreferredCodec, r.config.MaxSampleRate, r.config.MaxBitDepth) + logAdvertisedFormats(supportedFormats, r.config.MaxSampleRate, r.config.MaxBitDepth) + + clientConfig := protocol.Config{ + ServerAddr: r.serverAddr, + ClientID: r.config.ClientID, + Name: r.config.PlayerName, + Version: 1, + DeviceInfo: protocol.DeviceInfo{ + ProductName: r.config.DeviceInfo.ProductName, + Manufacturer: r.config.DeviceInfo.Manufacturer, + SoftwareVersion: r.config.DeviceInfo.SoftwareVersion, + }, + PlayerV1Support: protocol.PlayerV1Support{ + SupportedFormats: supportedFormats, + BufferCapacity: r.config.BufferCapacity, + SupportedCommands: []string{"volume", "mute"}, + }, + ArtworkV1Support: &protocol.ArtworkV1Support{ + Channels: []protocol.ArtworkChannel{ + {Source: "album", Format: "jpeg", MediaWidth: 600, MediaHeight: 600}, + }, + }, + VisualizerV1Support: &protocol.VisualizerV1Support{ + BufferCapacity: r.config.BufferCapacity, + }, + } + + r.client = protocol.NewClient(clientConfig) + + if err := r.client.Connect(); err != nil { + return fmt.Errorf("connection failed: %w", err) + } + + log.Printf("Connected to server: %s", r.serverAddr) + r.connected = true + + if err := r.performInitialSync(); err != nil { + log.Printf("Initial clock sync failed: %v", err) + } + + go r.watchConnection() + go r.clockSyncLoop() + go r.handleStreamStart() + go r.handleStreamClear() + go r.handleStreamEnd() + go r.handleAudioChunks() + go r.handleServerState() + go r.handleGroupUpdates() + go r.handleControl() + go r.metadataApplyLoop() + + return nil +} + +func (r *Receiver) handleStreamStart() { + for { + select { + case start := <-r.client.StreamStart: + if start.Player == nil { + log.Printf("Received stream/start with no player info") + continue + } + + log.Printf("Stream starting: %s %dHz %dch %dbit", + start.Player.Codec, start.Player.SampleRate, start.Player.Channels, start.Player.BitDepth) + + format := audio.Format{ + Codec: start.Player.Codec, + SampleRate: start.Player.SampleRate, + Channels: start.Player.Channels, + BitDepth: start.Player.BitDepth, + } + + if start.Player.CodecHeader != "" { + headerBytes, err := base64.StdEncoding.DecodeString(start.Player.CodecHeader) + if err != nil { + log.Printf("Failed to decode codec_header: %v", err) + } else { + format.CodecHeader = headerBytes + } + } + + var decoder decode.Decoder + var err error + + if r.config.DecoderFactory != nil { + decoder, err = r.config.DecoderFactory(format) + } else { + decoder, err = r.defaultDecoder(format) + } + + if err != nil { + r.notifyError(fmt.Errorf("failed to create decoder: %w", err)) + continue + } + r.decoder = decoder + r.format = format + + if r.config.OnStreamStart != nil { + r.config.OnStreamStart(format) + } + + if r.schedulerCancel != nil { + r.schedulerCancel() + } + if r.scheduler != nil { + r.scheduler.Stop() + } + + r.schedulerCtx, r.schedulerCancel = context.WithCancel(r.ctx) + r.scheduler = NewScheduler(r.clockSync, r.config.BufferMs, r.config.StaticDelayMs) + go r.scheduler.Run() + go r.pumpSchedulerOutput(r.schedulerCtx) + + case <-r.ctx.Done(): + return + } + } +} + +func (r *Receiver) defaultDecoder(format audio.Format) (decode.Decoder, error) { + switch format.Codec { + case "pcm": + return decode.NewPCM(format) + case "opus": + return decode.NewOpus(format) + case "flac": + return decode.NewFLAC(format) + default: + return nil, fmt.Errorf("unsupported codec: %s", format.Codec) + } +} + +func (r *Receiver) handleAudioChunks() { + for { + select { + case chunk := <-r.client.AudioChunks: + if r.decoder == nil || r.scheduler == nil { + continue + } + + pcm, err := r.decoder.Decode(chunk.Data) + if err != nil { + r.notifyError(fmt.Errorf("decode error: %w", err)) + continue + } + if len(pcm) == 0 { + continue + } + + buf := audio.Buffer{ + Timestamp: chunk.Timestamp, + Samples: pcm, + Format: r.format, + } + r.scheduler.Schedule(buf) + + case <-r.ctx.Done(): + return + } + } +} + +func (r *Receiver) pumpSchedulerOutput(ctx context.Context) { + for { + select { + case buf := <-r.scheduler.Output(): + select { + case r.output <- buf: + case <-ctx.Done(): + return + } + case <-ctx.Done(): + return + } + } +} + +func (r *Receiver) handleStreamClear() { + for { + select { + case clear := <-r.client.StreamClear: + log.Printf("Stream clear received for roles: %v", clear.Roles) + if len(clear.Roles) == 0 || containsRole(clear.Roles, "player") { + if r.scheduler != nil { + r.scheduler.Clear() + } + } + case <-r.ctx.Done(): + return + } + } +} + +func (r *Receiver) handleStreamEnd() { + for { + select { + case end := <-r.client.StreamEnd: + log.Printf("Stream end received for roles: %v", end.Roles) + if len(end.Roles) == 0 || containsRole(end.Roles, "player") { + if r.config.OnStreamEnd != nil { + r.config.OnStreamEnd() + } + } + case <-r.ctx.Done(): + return + } + } +} + +// handleServerState reads server/state messages from the protocol client +// and feeds metadata updates into the merge layer. Non-metadata fields +// of ServerStateMessage are not used today; if/when they grow handlers +// they should branch off here. +func (r *Receiver) handleServerState() { + for { + select { + case state := <-r.client.ServerState: + if state.Metadata != nil { + r.enqueueMetadata(state.Metadata) + } + case <-r.ctx.Done(): + return + } + } +} + +// enqueueMetadata accepts a server metadata update and either applies it +// immediately (timestamp <= current server time, or zero timestamp) or +// queues it for future application sorted by ascending timestamp. +// +// Zero / negative timestamps apply immediately. Per spec, MetadataState +// always carries a timestamp, but defending against malformed servers +// costs nothing and keeps existing snapshot-style emitters working. +func (r *Receiver) enqueueMetadata(m *protocol.MetadataState) { + r.metadataMu.Lock() + defer r.metadataMu.Unlock() + + serverNow := r.clockNow() + + if m.Timestamp <= 0 || m.Timestamp <= serverNow { + r.applyMetadataLocked(m) + return + } + + // Insertion-sort into pendingMetadata by ascending Timestamp. + idx := sort.Search(len(r.pendingMetadata), func(i int) bool { + return r.pendingMetadata[i].Timestamp >= m.Timestamp + }) + r.pendingMetadata = append(r.pendingMetadata, nil) + copy(r.pendingMetadata[idx+1:], r.pendingMetadata[idx:]) + r.pendingMetadata[idx] = m +} + +// applyMetadataLocked merges the update onto mergedMetadata per tristate +// rules and fires OnMetadata. Caller must hold r.metadataMu. +// +// For each field: if the wire key was absent, preserve the prior value; +// if present and null (pointer is nil after decode), reset to zero; if +// present with a value, replace. The progress field is atomic per spec — +// a non-null progress always carries all three fields, so we either take +// TrackDuration or zero Duration. +func (r *Receiver) applyMetadataLocked(m *protocol.MetadataState) { + if m.HasField("title") { + if m.Title != nil { + r.mergedMetadata.Title = *m.Title + } else { + r.mergedMetadata.Title = "" + } + } + if m.HasField("artist") { + if m.Artist != nil { + r.mergedMetadata.Artist = *m.Artist + } else { + r.mergedMetadata.Artist = "" + } + } + if m.HasField("album") { + if m.Album != nil { + r.mergedMetadata.Album = *m.Album + } else { + r.mergedMetadata.Album = "" + } + } + if m.HasField("album_artist") { + if m.AlbumArtist != nil { + r.mergedMetadata.AlbumArtist = *m.AlbumArtist + } else { + r.mergedMetadata.AlbumArtist = "" + } + } + if m.HasField("artwork_url") { + if m.ArtworkURL != nil { + r.mergedMetadata.ArtworkURL = *m.ArtworkURL + } else { + r.mergedMetadata.ArtworkURL = "" + } + } + if m.HasField("track") { + if m.Track != nil { + r.mergedMetadata.Track = *m.Track + } else { + r.mergedMetadata.Track = 0 + } + } + if m.HasField("year") { + if m.Year != nil { + r.mergedMetadata.Year = *m.Year + } else { + r.mergedMetadata.Year = 0 + } + } + if m.HasField("progress") { + if m.Progress != nil { + r.mergedMetadata.Duration = m.Progress.TrackDuration / 1000 + } else { + r.mergedMetadata.Duration = 0 + } + } + + snapshot := r.mergedMetadata + if r.config.OnMetadata != nil { + r.config.OnMetadata(snapshot) + } +} + +// metadataApplyLoop drains pendingMetadata as server time crosses each +// queued update's timestamp. Started as a goroutine in Connect. +func (r *Receiver) metadataApplyLoop() { + ticker := time.NewTicker(metadataApplyTickInterval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + r.drainPendingMetadata() + case <-r.ctx.Done(): + return + } + } +} + +// drainPendingMetadata applies every pending update whose timestamp has +// elapsed, in ascending timestamp order. Pending is kept sorted by +// enqueueMetadata, so we can stop at the first future-dated entry. +func (r *Receiver) drainPendingMetadata() { + r.metadataMu.Lock() + defer r.metadataMu.Unlock() + + serverNow := r.clockNow() + applied := 0 + for _, m := range r.pendingMetadata { + if m.Timestamp > serverNow { + break + } + r.applyMetadataLocked(m) + applied++ + } + if applied > 0 { + r.pendingMetadata = r.pendingMetadata[applied:] + } +} + +func (r *Receiver) handleGroupUpdates() { + for { + select { + case update := <-r.client.GroupUpdate: + if update.PlaybackState != nil { + state := *update.PlaybackState + log.Printf("Group playback state: %s", state) + if (state == "paused" || state == "stopped") && r.scheduler != nil { + r.scheduler.Clear() + } + } + if update.GroupID != nil { + log.Printf("Joined group: %s", *update.GroupID) + } + case <-r.ctx.Done(): + return + } + } +} + +func (r *Receiver) handleControl() { + for { + select { + case cmd := <-r.client.ControlMsgs: + if r.config.OnControl != nil { + r.config.OnControl(cmd) + } + case <-r.ctx.Done(): + return + } + } +} + +// watchConnection monitors the protocol client and cancels the receiver context +// if the connection is lost, ensuring all goroutines exit cleanly. +func (r *Receiver) watchConnection() { + select { + case <-r.client.Done(): + log.Printf("Server connection lost, shutting down receiver") + r.connected = false + r.notifyError(fmt.Errorf("server connection lost")) + r.cancel() + case <-r.ctx.Done(): + return + } +} + +// performInitialSync drives a single immediate burst so the filter has +// multiple samples before the audio scheduler starts. +func (r *Receiver) performInitialSync() error { + log.Printf("Performing initial clock synchronization (burst of %d)...", timeSyncBurstSize) + r.runTimeSyncBurst(timeSyncBurstSize) + + rtt, quality := r.clockSync.GetStats() + log.Printf("Initial clock sync complete: rtt=%dus, quality=%v", rtt, quality) + return nil +} + +// clockSyncLoop fires a time-sync burst every timeSyncBurstInterval. The old +// per-second single-message pattern was replaced by the burst-best strategy +// recommended by the upstream Sendspin/time-filter README and implemented by +// sendspin-cpp's TimeBurst — it converges faster and rejects high-RTT +// outliers without an explicit threshold. +func (r *Receiver) clockSyncLoop() { + ticker := time.NewTicker(timeSyncBurstInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + r.runTimeSyncBurst(timeSyncBurstSize) + case <-r.ctx.Done(): + return + } + } +} + +// runTimeSyncBurst sends `size` time messages back-to-back, each waiting for +// its reply, tracks the sample with the lowest RTT, and feeds only that best +// sample to the clock-sync filter at burst end. Mirrors sendspin-cpp's +// TimeBurst loop. Strictly serial — bursts run on TCP/WebSocket where a +// delayed earlier message also delays its successors, so parallel sends +// would not give independent RTT measurements. +func (r *Receiver) runTimeSyncBurst(size int) { + // Drain any responses left over from a prior burst (e.g. a timed-out + // reply that arrived after the per-message timeout fired). Keeps the + // next-message recv from picking up a stale sample. +drainLoop: + for { + select { + case <-r.client.TimeSyncResp: + default: + break drainLoop + } + } + + var ( + bestT1, bestT2, bestT3, bestT4 int64 + bestRTT int64 = math.MaxInt64 + valid = 0 + ) + + for i := 0; i < size; i++ { + t1 := time.Now().UnixMicro() + if err := r.client.SendTimeSync(t1); err != nil { + log.Printf("Burst send %d/%d failed: %v", i+1, size, err) + continue + } + + select { + case resp := <-r.client.TimeSyncResp: + t4 := time.Now().UnixMicro() + rtt := (t4 - resp.ClientTransmitted) - (resp.ServerTransmitted - resp.ServerReceived) + if rtt < bestRTT { + bestRTT = rtt + bestT1 = resp.ClientTransmitted + bestT2 = resp.ServerReceived + bestT3 = resp.ServerTransmitted + bestT4 = t4 + } + valid++ + case <-time.After(timeSyncResponseTimeout): + log.Printf("Burst sample %d/%d timed out", i+1, size) + case <-r.ctx.Done(): + return + } + } + + if valid == 0 { + log.Printf("Burst produced 0 valid samples; filter not updated") + return + } + + r.clockSync.ProcessSyncResponse(bestT1, bestT2, bestT3, bestT4) +} + +// buildSupportedFormats returns the player's advertised format list, +// optionally filtered by maxSampleRate / maxBitDepth (0 = no cap) and +// reordered so preferredCodec entries come first when set. +// +// Filter happens before reorder, so the surviving preferred-codec entries +// stay grouped at the head. Returns an empty slice when the caps exclude +// every format — callers can detect that and fail loudly. We do NOT +// fabricate a fallback entry: if the user asks for caps no format can +// satisfy, the honest response is empty, and the resulting handshake +// failure is the right user-visible signal. +func buildSupportedFormats(preferredCodec string, maxSampleRate, maxBitDepth int) []protocol.AudioFormat { + allFormats := []protocol.AudioFormat{ + {Codec: "pcm", Channels: 2, SampleRate: 192000, BitDepth: 24}, + {Codec: "pcm", Channels: 2, SampleRate: 176400, BitDepth: 24}, + {Codec: "pcm", Channels: 2, SampleRate: 96000, BitDepth: 24}, + {Codec: "pcm", Channels: 2, SampleRate: 88200, BitDepth: 24}, + {Codec: "pcm", Channels: 2, SampleRate: 48000, BitDepth: 16}, + {Codec: "pcm", Channels: 2, SampleRate: 44100, BitDepth: 16}, + {Codec: "flac", Channels: 2, SampleRate: 192000, BitDepth: 24}, + {Codec: "flac", Channels: 2, SampleRate: 96000, BitDepth: 24}, + {Codec: "flac", Channels: 2, SampleRate: 48000, BitDepth: 24}, + {Codec: "flac", Channels: 2, SampleRate: 44100, BitDepth: 16}, + {Codec: "opus", Channels: 2, SampleRate: 48000, BitDepth: 16}, + } + + filtered := make([]protocol.AudioFormat, 0, len(allFormats)) + for _, f := range allFormats { + if maxSampleRate > 0 && f.SampleRate > maxSampleRate { + continue + } + if maxBitDepth > 0 && f.BitDepth > maxBitDepth { + continue + } + filtered = append(filtered, f) + } + + if preferredCodec == "" { + return filtered + } + + // Move preferred codec formats to the front while preserving original + // order within each group. + preferred := make([]protocol.AudioFormat, 0, len(filtered)) + rest := make([]protocol.AudioFormat, 0, len(filtered)) + for _, f := range filtered { + if f.Codec == preferredCodec { + preferred = append(preferred, f) + } else { + rest = append(rest, f) + } + } + return append(preferred, rest...) +} + +// logAdvertisedFormats writes one summary line describing what the player +// is about to send in client/hello — codec set, max rate, max depth, and +// the cap that produced the list. Operators reading the log can answer +// "what did this player say it could do?" without having to inspect server +// traces. +// +// Empty list goes out as a WARNING: the resulting handshake produces only +// a generic negotiation failure, so surfacing the cause player-side saves +// users from chasing the same symptom on the server. +func logAdvertisedFormats(formats []protocol.AudioFormat, maxSampleRate, maxBitDepth int) { + capDesc := "no cap" + if maxSampleRate > 0 || maxBitDepth > 0 { + capDesc = fmt.Sprintf("cap %dHz/%d-bit", maxSampleRate, maxBitDepth) + } + if len(formats) == 0 { + log.Printf("WARNING: advertising 0 supported formats (%s) — handshake will fail; relax the caps", capDesc) + return + } + codecsSeen := make(map[string]struct{}, 3) + codecsOrdered := make([]string, 0, 3) + var maxRate, maxDepth int + for _, f := range formats { + if _, ok := codecsSeen[f.Codec]; !ok { + codecsSeen[f.Codec] = struct{}{} + codecsOrdered = append(codecsOrdered, f.Codec) + } + if f.SampleRate > maxRate { + maxRate = f.SampleRate + } + if f.BitDepth > maxDepth { + maxDepth = f.BitDepth + } + } + log.Printf("Advertising %d supported formats: codecs=[%s] max=%dHz/%d-bit (%s)", + len(formats), strings.Join(codecsOrdered, ","), maxRate, maxDepth, capDesc) +} + +func (r *Receiver) notifyError(err error) { + if r.config.OnError != nil { + r.config.OnError(err) + } else { + log.Printf("Receiver error: %v", err) + } +} + +func (r *Receiver) Close() error { + // Send goodbye BEFORE cancelling the context so the message + // reaches the server while the connection is still alive. + if r.client != nil { + r.client.SendGoodbye("shutdown") + } + + r.cancel() + + if r.client != nil { + r.client.Close() + } + + if r.scheduler != nil { + r.scheduler.Stop() + } + + if r.decoder != nil { + if err := r.decoder.Close(); err != nil { + log.Printf("Receiver: decoder close error: %v", err) + } + } + + close(r.output) + + return nil +} diff --git a/third_party/sendspin-go/pkg/sendspin/receiver_test.go b/third_party/sendspin-go/pkg/sendspin/receiver_test.go new file mode 100644 index 0000000..97fb2de --- /dev/null +++ b/third_party/sendspin-go/pkg/sendspin/receiver_test.go @@ -0,0 +1,595 @@ +// ABOUTME: Tests for Receiver type construction and basic lifecycle +package sendspin + +import ( + "encoding/json" + "fmt" + "sync" + "testing" + + "github.com/Sendspin/sendspin-go/pkg/audio" + "github.com/Sendspin/sendspin-go/pkg/audio/decode" + "github.com/Sendspin/sendspin-go/pkg/protocol" +) + +func TestNewReceiver_Defaults(t *testing.T) { + config := ReceiverConfig{ + ServerAddr: "localhost:8927", + PlayerName: "Test Receiver", + } + + r, err := NewReceiver(config) + if err != nil { + t.Fatalf("NewReceiver failed: %v", err) + } + defer r.Close() + + if r == nil { + t.Fatal("Expected non-nil Receiver") + } + + if r.clockSync == nil { + t.Error("Expected clockSync to be initialized") + } + + if r.Output() == nil { + t.Error("Expected Output() channel to be non-nil") + } + + if r.config.BufferMs != 500 { + t.Errorf("Expected default BufferMs=500, got %d", r.config.BufferMs) + } + + if r.config.DeviceInfo.ProductName == "" { + t.Error("Expected default DeviceInfo.ProductName to be set") + } + + if r.config.DeviceInfo.Manufacturer == "" { + t.Error("Expected default DeviceInfo.Manufacturer to be set") + } + + if r.config.DeviceInfo.SoftwareVersion == "" { + t.Error("Expected default DeviceInfo.SoftwareVersion to be set") + } +} + +func TestNewReceiver_RequiresServerAddr(t *testing.T) { + config := ReceiverConfig{ + PlayerName: "Test Receiver", + // ServerAddr intentionally omitted + } + + r, err := NewReceiver(config) + if err == nil { + t.Error("Expected error when ServerAddr is empty") + if r != nil { + r.Close() + } + } + + if r != nil { + t.Error("Expected nil Receiver on error") + } +} + +func TestReceiver_Connect_BadAddress(t *testing.T) { + recv, _ := NewReceiver(ReceiverConfig{ + ServerAddr: "localhost:99999", + PlayerName: "Test", + }) + + err := recv.Connect() + if err == nil { + t.Fatal("expected connection error for bad address") + } +} + +func TestReceiver_ClockSyncIsOwnInstance(t *testing.T) { + config1 := ReceiverConfig{ + ServerAddr: "localhost:8927", + PlayerName: "Receiver One", + } + config2 := ReceiverConfig{ + ServerAddr: "localhost:8928", + PlayerName: "Receiver Two", + } + + r1, err := NewReceiver(config1) + if err != nil { + t.Fatalf("NewReceiver r1 failed: %v", err) + } + defer r1.Close() + + r2, err := NewReceiver(config2) + if err != nil { + t.Fatalf("NewReceiver r2 failed: %v", err) + } + defer r2.Close() + + if r1.ClockSync() == r2.ClockSync() { + t.Error("Expected each Receiver to have its own ClockSync instance") + } +} + +func TestReceiver_CloseBeforeConnect(t *testing.T) { + recv, _ := NewReceiver(ReceiverConfig{ + ServerAddr: "localhost:8927", + PlayerName: "Test", + }) + + // Should not panic + err := recv.Close() + if err != nil { + t.Fatalf("unexpected error closing unconnected receiver: %v", err) + } +} + +func TestReceiver_StatsBeforeConnect(t *testing.T) { + recv, _ := NewReceiver(ReceiverConfig{ + ServerAddr: "localhost:8927", + PlayerName: "Test", + }) + + stats := recv.Stats() + if stats.Received != 0 || stats.Played != 0 || stats.Dropped != 0 { + t.Error("expected zero stats before connect") + } +} + +func TestReceiver_OnStreamStartCallback(t *testing.T) { + var receivedFormat audio.Format + _, err := NewReceiver(ReceiverConfig{ + ServerAddr: "localhost:8927", + PlayerName: "Test", + OnStreamStart: func(f audio.Format) { + receivedFormat = f + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Callback is stored but not invoked until stream starts + if receivedFormat.Codec != "" { + t.Error("expected empty format before stream start") + } +} + +func TestReceiver_CustomDecoderFactory(t *testing.T) { + factoryCalled := false + recv, _ := NewReceiver(ReceiverConfig{ + ServerAddr: "localhost:8927", + PlayerName: "Test", + DecoderFactory: func(f audio.Format) (decode.Decoder, error) { + factoryCalled = true + return nil, fmt.Errorf("test decoder") + }, + }) + + if recv.config.DecoderFactory == nil { + t.Error("expected DecoderFactory to be stored") + } + if factoryCalled { + t.Error("factory should not be called before connect") + } +} + +// maxRateAndDepth scans formats and returns the largest sample rate and bit +// depth present. Test helper: lets assertions describe the cap by intent +// ("nothing above 48k/16") rather than counting list entries. +func maxRateAndDepth(formats []protocol.AudioFormat) (int, int) { + var maxRate, maxDepth int + for _, f := range formats { + if f.SampleRate > maxRate { + maxRate = f.SampleRate + } + if f.BitDepth > maxDepth { + maxDepth = f.BitDepth + } + } + return maxRate, maxDepth +} + +func TestBuildSupportedFormats_NoCaps(t *testing.T) { + got := buildSupportedFormats("", 0, 0) + if len(got) == 0 { + t.Fatal("expected non-empty format list with no caps") + } + maxRate, maxDepth := maxRateAndDepth(got) + if maxRate != 192000 { + t.Errorf("expected max rate 192000 with no caps, got %d", maxRate) + } + if maxDepth != 24 { + t.Errorf("expected max depth 24 with no caps, got %d", maxDepth) + } +} + +func TestBuildSupportedFormats_RateCap(t *testing.T) { + got := buildSupportedFormats("", 48000, 0) + if len(got) == 0 { + t.Fatal("expected non-empty list with 48000Hz cap") + } + for _, f := range got { + if f.SampleRate > 48000 { + t.Errorf("expected no rate > 48000, got %s @ %dHz", f.Codec, f.SampleRate) + } + } + // Boundary: 48000Hz formats must survive. + hasFortyEight := false + for _, f := range got { + if f.SampleRate == 48000 { + hasFortyEight = true + break + } + } + if !hasFortyEight { + t.Error("expected at least one 48000Hz format to survive the cap") + } +} + +func TestBuildSupportedFormats_BitDepthCap(t *testing.T) { + got := buildSupportedFormats("", 0, 16) + if len(got) == 0 { + t.Fatal("expected non-empty list with 16-bit cap") + } + for _, f := range got { + if f.BitDepth > 16 { + t.Errorf("expected no depth > 16, got %s @ %d-bit", f.Codec, f.BitDepth) + } + } +} + +func TestBuildSupportedFormats_BothCaps(t *testing.T) { + got := buildSupportedFormats("", 48000, 16) + if len(got) == 0 { + t.Fatal("expected non-empty list with 48k/16 caps") + } + for _, f := range got { + if f.SampleRate > 48000 || f.BitDepth > 16 { + t.Errorf("expected no entries beyond 48000Hz/16-bit, got %s @ %dHz/%d-bit", + f.Codec, f.SampleRate, f.BitDepth) + } + } +} + +func TestBuildSupportedFormats_PreferredCodecAfterFilter(t *testing.T) { + // Filter eliminates high-res FLAC; the survivor should still be ordered + // with FLAC at the front when preferredCodec="flac". + got := buildSupportedFormats("flac", 48000, 24) + if len(got) == 0 { + t.Fatal("expected non-empty list") + } + if got[0].Codec != "flac" { + t.Errorf("expected preferred codec at front, got %s", got[0].Codec) + } +} + +func TestBuildSupportedFormats_CapsAtExactBoundary(t *testing.T) { + // maxSampleRate=192000, maxBitDepth=24 should keep everything. + full := buildSupportedFormats("", 0, 0) + bounded := buildSupportedFormats("", 192000, 24) + if len(bounded) != len(full) { + t.Errorf("boundary cap should keep all formats: full=%d bounded=%d", len(full), len(bounded)) + } +} + +func TestBuildSupportedFormats_AggressiveCapEmpty(t *testing.T) { + // User asks for ≤ 8000Hz / ≤ 8-bit. Nothing in our list matches; we + // honestly return empty rather than fabricating a fallback. The handshake + // will fail, which is the right user-visible signal that the cap is wrong. + got := buildSupportedFormats("", 8000, 8) + if len(got) != 0 { + t.Errorf("expected empty list for impossible caps, got %d entries", len(got)) + } +} + +// metadataStateWithKeys builds a MetadataState by JSON-marshaling the input +// map and decoding through the real wire path so presentKeys is populated +// correctly. This is the only way to construct a MetadataState whose +// HasField returns false for unspecified keys, since presentKeys is +// unexported. Tests that rely on tristate semantics must use this helper. +func metadataStateWithKeys(t *testing.T, fields map[string]any) *protocol.MetadataState { + t.Helper() + data, err := json.Marshal(fields) + if err != nil { + t.Fatalf("metadataStateWithKeys: marshal: %v", err) + } + var m protocol.MetadataState + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("metadataStateWithKeys: unmarshal: %v", err) + } + return &m +} + +// receiverForMetadataTests builds a Receiver with a fixed fake clock and an +// OnMetadata callback that captures every snapshot. The captured slice is +// guarded by its own mutex because OnMetadata may be invoked from either +// the channel reader goroutine or the apply-loop goroutine. +type metadataCapture struct { + mu sync.Mutex + captured []Metadata +} + +func (mc *metadataCapture) record(m Metadata) { + mc.mu.Lock() + defer mc.mu.Unlock() + mc.captured = append(mc.captured, m) +} + +func (mc *metadataCapture) latest() (Metadata, bool) { + mc.mu.Lock() + defer mc.mu.Unlock() + if len(mc.captured) == 0 { + return Metadata{}, false + } + return mc.captured[len(mc.captured)-1], true +} + +func (mc *metadataCapture) count() int { + mc.mu.Lock() + defer mc.mu.Unlock() + return len(mc.captured) +} + +// newMetadataReceiver returns a Receiver wired up for direct enqueue/apply +// testing — no Connect, no protocol client, no scheduler. clockNow is +// overridden to a fixed value so timestamp-deferral tests are deterministic. +func newMetadataReceiver(t *testing.T, fakeNow int64, mc *metadataCapture) *Receiver { + t.Helper() + r, err := NewReceiver(ReceiverConfig{ + ServerAddr: "localhost:8927", + PlayerName: "Metadata Test", + OnMetadata: mc.record, + }) + if err != nil { + t.Fatalf("NewReceiver: %v", err) + } + r.clockNow = func() int64 { return fakeNow } + t.Cleanup(func() { _ = r.Close() }) + return r +} + +// TestReceiver_MetadataMergePreservesUnchangedFields is the headline +// regression: a track A snapshot followed by a track B diff_update that +// only carries `title` must not blank out artist/album. Pre-fix, the +// receiver dereffed nil pointers and dispatched empty strings for both. +func TestReceiver_MetadataMergePreservesUnchangedFields(t *testing.T) { + mc := &metadataCapture{} + r := newMetadataReceiver(t, 0, mc) + + // Snapshot: title + artist + album set. + r.enqueueMetadata(metadataStateWithKeys(t, map[string]any{ + "timestamp": 0, + "title": "Song A", + "artist": "Artist X", + "album": "Album Y", + })) + + // Diff: only title changes. Artist + album omitted → must preserve. + r.enqueueMetadata(metadataStateWithKeys(t, map[string]any{ + "timestamp": 0, + "title": "Song B", + })) + + got, ok := mc.latest() + if !ok { + t.Fatal("OnMetadata was not invoked") + } + t.Logf("merged snapshot after diff: %+v", got) + if got.Title != "Song B" { + t.Errorf("Title = %q, want %q", got.Title, "Song B") + } + if got.Artist != "Artist X" { + t.Errorf("Artist = %q, want %q (must be preserved across diff)", got.Artist, "Artist X") + } + if got.Album != "Album Y" { + t.Errorf("Album = %q, want %q (must be preserved across diff)", got.Album, "Album Y") + } + if mc.count() != 2 { + t.Errorf("OnMetadata invocation count = %d, want 2", mc.count()) + } +} + +// TestReceiver_MetadataNullClearsField verifies the "null" leg of the +// tristate contract: a wire-explicit null must reset the prior value. +func TestReceiver_MetadataNullClearsField(t *testing.T) { + mc := &metadataCapture{} + r := newMetadataReceiver(t, 0, mc) + + r.enqueueMetadata(metadataStateWithKeys(t, map[string]any{ + "timestamp": 0, + "title": "Song A", + "artist": "Artist X", + "artwork_url": "http://example/a.jpg", + })) + + // Explicit null on artwork_url and artist: must clear both. + r.enqueueMetadata(metadataStateWithKeys(t, map[string]any{ + "timestamp": 0, + "artist": nil, + "artwork_url": nil, + })) + + got, ok := mc.latest() + if !ok { + t.Fatal("OnMetadata was not invoked") + } + t.Logf("merged snapshot after null-clear: %+v", got) + if got.Title != "Song A" { + t.Errorf("Title = %q, want %q (omitted, should be preserved)", got.Title, "Song A") + } + if got.Artist != "" { + t.Errorf("Artist = %q, want empty (null on wire clears it)", got.Artist) + } + if got.ArtworkURL != "" { + t.Errorf("ArtworkURL = %q, want empty (null on wire clears it)", got.ArtworkURL) + } +} + +// TestReceiver_MetadataFutureTimestampDeferred verifies a future-dated +// update is held in pendingMetadata until the fake clock advances past +// the timestamp, then drainPendingMetadata flushes it. +func TestReceiver_MetadataFutureTimestampDeferred(t *testing.T) { + mc := &metadataCapture{} + // Fake clock starts at server-time = 1000 µs. + r, err := NewReceiver(ReceiverConfig{ + ServerAddr: "localhost:8927", + PlayerName: "Defer Test", + OnMetadata: mc.record, + }) + if err != nil { + t.Fatalf("NewReceiver: %v", err) + } + t.Cleanup(func() { _ = r.Close() }) + + var now int64 = 1000 + r.clockNow = func() int64 { return now } + + // Apply-now snapshot to set baseline. + r.enqueueMetadata(metadataStateWithKeys(t, map[string]any{ + "timestamp": 0, + "title": "Now Playing", + "artist": "Current Artist", + })) + if mc.count() != 1 { + t.Fatalf("expected 1 immediate apply, got %d", mc.count()) + } + + // Future-dated update at server-time = 5000 µs. Should NOT apply yet. + r.enqueueMetadata(metadataStateWithKeys(t, map[string]any{ + "timestamp": 5000, + "title": "Up Next", + })) + if mc.count() != 1 { + t.Errorf("future-dated update applied early; OnMetadata count = %d, want 1", mc.count()) + } + + r.metadataMu.Lock() + pendingLen := len(r.pendingMetadata) + r.metadataMu.Unlock() + if pendingLen != 1 { + t.Errorf("pendingMetadata length = %d, want 1", pendingLen) + } + + // Drain at clock = 4000 µs (still before timestamp): nothing applies. + now = 4000 + r.drainPendingMetadata() + if mc.count() != 1 { + t.Errorf("drain at clock %s (must not broadcast)", tc.oldState, tc.newState) + meta.OnPlaybackStateChanged(tc.oldState, tc.newState) + expectNoSend(t, sc, 30*time.Millisecond, tc.oldState+"->"+tc.newState) + } + + // Sanity: stopped -> playing IS broadcast. + t.Log("sanity: stopped -> playing must broadcast") + meta.OnPlaybackStateChanged("stopped", "playing") + msg, state := drainServerStateMessage(t, sc) + t.Logf("sanity broadcast received: type=%s metadata=%+v", msg.Type, state.Metadata) +} + +// TestMetadataRole_BroadcastMetadata_BroadcastsRegardlessOfState confirms +// that the public BroadcastMetadata method pushes the snapshot to every +// metadata-role client unconditionally. +func TestMetadataRole_BroadcastMetadata_BroadcastsRegardlessOfState(t *testing.T) { + g := NewGroup("test-group") + defer g.Close() + + meta := NewMetadataRole(MetadataConfig{ + GetMetadata: func() (string, string, string) { + return "Updated Title", "Updated Artist", "Updated Album" + }, + ClockMicros: func() int64 { return 100 }, + }) + g.RegisterRole(meta) + + sc1 := &ServerClient{ + id: "meta-1", + roles: []string{"metadata@v1"}, + sendChan: make(chan interface{}, 10), + } + sc2 := &ServerClient{ + id: "meta-2", + roles: []string{"metadata@v2"}, // versioned form must still match + sendChan: make(chan interface{}, 10), + } + scPlayer := &ServerClient{ + id: "player-1", + roles: []string{"player@v1"}, + sendChan: make(chan interface{}, 10), + } + + g.addClient(sc1) + g.addClient(sc2) + g.addClient(scPlayer) + <-sc1.sendChan + <-sc2.sendChan + <-scPlayer.sendChan + + // Drain join-time server/state on the metadata clients. + time.Sleep(20 * time.Millisecond) + for _, sc := range []*ServerClient{sc1, sc2} { + select { + case <-sc.sendChan: + default: + } + } + + meta.BroadcastMetadata() + + msg1, state1 := drainServerStateMessage(t, sc1) + t.Logf("sc1 (metadata@v1) received: type=%s metadata=%+v", msg1.Type, state1.Metadata) + if state1.Metadata == nil || state1.Metadata.Title == nil || *state1.Metadata.Title != "Updated Title" { + t.Errorf("sc1: title = %v, want \"Updated Title\"", state1.Metadata) + } + + msg2, state2 := drainServerStateMessage(t, sc2) + t.Logf("sc2 (metadata@v2) received: type=%s metadata=%+v", msg2.Type, state2.Metadata) + if state2.Metadata == nil || state2.Metadata.Title == nil || *state2.Metadata.Title != "Updated Title" { + t.Errorf("sc2: title = %v, want \"Updated Title\"", state2.Metadata) + } + + expectNoSend(t, scPlayer, 30*time.Millisecond, "player-only client") +} + +// TestMetadataRole_OnPlaybackStateChanged_FiresFromGroupEvent is the +// end-to-end wiring proof: register the role on a real Group, attach a +// metadata-role client, call g.SetPlaybackState("stopped") then +// g.SetPlaybackState("playing"), assert the client received the broadcast +// via the dispatcher path. +func TestMetadataRole_OnPlaybackStateChanged_FiresFromGroupEvent(t *testing.T) { + g := NewGroup("test-group") + defer g.Close() + + meta := NewMetadataRole(MetadataConfig{ + GetMetadata: func() (string, string, string) { + return "E2E Title", "E2E Artist", "E2E Album" + }, + ClockMicros: func() int64 { return 7 }, + }) + g.RegisterRole(meta) + + sc := &ServerClient{ + id: "meta-c", + name: "metadata client", + roles: []string{"metadata@v1"}, + sendChan: make(chan interface{}, 10), + } + g.addClient(sc) + <-sc.sendChan // group/update + + // Drain join-time server/state. + time.Sleep(20 * time.Millisecond) + select { + case msg := <-sc.sendChan: + t.Logf("drained join-time send: %+v", msg) + default: + } + + // Default playback state is "playing"; transition through stopped to + // trigger an actual stopped→playing edge. + g.SetPlaybackState("stopped") + g.SetPlaybackState("playing") + + // Wait for the dispatcher to deliver, then assert. + deadline := time.After(200 * time.Millisecond) + for { + select { + case raw := <-sc.sendChan: + msg, ok := raw.(protocol.Message) + if !ok { + t.Fatalf("queued send was %T, want protocol.Message", raw) + } + if msg.Type != "server/state" { + continue + } + state, ok := msg.Payload.(protocol.ServerStateMessage) + if !ok { + t.Fatalf("Message.Payload was %T, want ServerStateMessage", msg.Payload) + } + t.Logf("E2E broadcast received: type=%s metadata=%+v", msg.Type, state.Metadata) + if state.Metadata == nil || state.Metadata.Title == nil || *state.Metadata.Title != "E2E Title" { + t.Errorf("E2E broadcast title = %v, want \"E2E Title\"", state.Metadata) + } + return + case <-deadline: + t.Fatal("timed out waiting for E2E broadcast via SetPlaybackState") + } + } +} + +// TestMetadataRole_BroadcastMetadata_ConcurrentCallsAreSafe runs many +// parallel BroadcastMetadata calls and asserts no panics, no races, and +// every call lands at least one send on the metadata client. The fake +// client's sendChan has capacity 200; the test bounds itself to fewer +// broadcasts so we don't need to drain in parallel. +func TestMetadataRole_BroadcastMetadata_ConcurrentCallsAreSafe(t *testing.T) { + g := NewGroup("test-group") + defer g.Close() + + meta := NewMetadataRole(MetadataConfig{ + GetMetadata: func() (string, string, string) { return "t", "a", "al" }, + ClockMicros: func() int64 { return 0 }, + }) + g.RegisterRole(meta) + + sc := &ServerClient{ + id: "meta-c", + roles: []string{"metadata@v1"}, + sendChan: make(chan interface{}, 200), + } + g.addClient(sc) + <-sc.sendChan + + // Drain any join-time send. + time.Sleep(20 * time.Millisecond) + select { + case <-sc.sendChan: + default: + } + + const callers = 10 + const perCaller = 5 + var wg sync.WaitGroup + for i := 0; i < callers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < perCaller; j++ { + meta.BroadcastMetadata() + } + }() + } + wg.Wait() + + count := 0 +loop: + for { + select { + case <-sc.sendChan: + count++ + default: + break loop + } + } + t.Logf("concurrent broadcasts queued %d sends (callers=%d, perCaller=%d)", count, callers, perCaller) + if count == 0 { + t.Error("expected at least one queued send from concurrent broadcasts, got 0") + } +} diff --git a/third_party/sendspin-go/pkg/sendspin/role_player.go b/third_party/sendspin-go/pkg/sendspin/role_player.go new file mode 100644 index 0000000..1235667 --- /dev/null +++ b/third_party/sendspin-go/pkg/sendspin/role_player.go @@ -0,0 +1,161 @@ +// ABOUTME: PlayerGroupRole handles player stream setup on client join +// ABOUTME: Codec negotiation, encoder creation, and stream/start message +package sendspin + +import ( + "encoding/base64" + "fmt" + "log" + + "github.com/Sendspin/sendspin-go/internal/server" + "github.com/Sendspin/sendspin-go/pkg/audio" + "github.com/Sendspin/sendspin-go/pkg/protocol" +) + +// PlayerRoleConfig configures the PlayerGroupRole. +type PlayerRoleConfig struct { + SampleRate int + Channels int + BitDepth int + + // NewEncoder creates an Opus encoder when needed. When nil, Opus + // clients fall back to PCM. Typically set to server.NewOpusEncoder. + NewEncoder func(sampleRate, channels, chunkSamples int) (*server.OpusEncoder, error) + + // NewFLACEncoder creates a FLAC encoder when needed. When nil, FLAC + // clients fall back to PCM. + NewFLACEncoder func(sampleRate, channels, bitDepth, blockSize int) (*server.FLACEncoder, error) +} + +// PlayerGroupRole handles the "player" role family. On client join it +// negotiates a codec, creates any needed encoder/resampler, and sends +// stream/start. The audio chunk pipeline (generateAndSendChunk) stays +// on Server — this role only handles the per-client setup. +type PlayerGroupRole struct { + config PlayerRoleConfig +} + +// NewPlayerRole creates a PlayerGroupRole with the given config. +func NewPlayerRole(config PlayerRoleConfig) *PlayerGroupRole { + return &PlayerGroupRole{config: config} +} + +func (r *PlayerGroupRole) Role() string { return "player" } + +// OnClientJoin negotiates a codec, creates encoder/resampler if needed, +// and sends stream/start to clients advertising the player role. +func (r *PlayerGroupRole) OnClientJoin(c *ServerClient) { + if !c.HasRole("player") { + return + } + + if c.capabilities != nil { + fmts := make([]string, 0, len(c.capabilities.SupportedFormats)) + for _, f := range c.capabilities.SupportedFormats { + fmts = append(fmts, fmt.Sprintf("%s@%dHz/%dbit/%dch", f.Codec, f.SampleRate, f.BitDepth, f.Channels)) + } + log.Printf("Client %s advertised formats (in order): %v", c.Name(), fmts) + } else { + log.Printf("Client %s advertised no capabilities", c.Name()) + } + + codec := negotiateCodec(c, r.config.SampleRate) + + var opusEncoder *server.OpusEncoder + var flacEncoder *server.FLACEncoder + var resampler *audio.Resampler + flacBitDepth := r.config.BitDepth + + switch codec { + case "opus": + if r.config.SampleRate != 48000 { + resampler = audio.NewResampler(r.config.SampleRate, 48000, r.config.Channels) + log.Printf("Created resampler: %dHz -> 48kHz for Opus (client: %s)", r.config.SampleRate, c.Name()) + } + + opusChunkSamples := (48000 * ChunkDurationMs) / 1000 + if r.config.NewEncoder != nil { + encoder, err := r.config.NewEncoder(48000, r.config.Channels, opusChunkSamples) + if err != nil { + log.Printf("Failed to create Opus encoder for %s, falling back to PCM: %v", c.Name(), err) + codec = "pcm" + resampler = nil + } else { + opusEncoder = encoder + } + } else { + log.Printf("No Opus encoder factory for %s, falling back to PCM", c.Name()) + codec = "pcm" + resampler = nil + } + case "flac": + if c.capabilities != nil { + for _, format := range c.capabilities.SupportedFormats { + if format.Codec == "flac" && format.SampleRate == r.config.SampleRate { + flacBitDepth = format.BitDepth + break + } + } + } + chunkSamples := (r.config.SampleRate * ChunkDurationMs) / 1000 + if r.config.NewFLACEncoder != nil { + encoder, err := r.config.NewFLACEncoder(r.config.SampleRate, r.config.Channels, flacBitDepth, chunkSamples) + if err != nil { + log.Printf("Failed to create FLAC encoder for %s, falling back to PCM: %v", c.Name(), err) + codec = "pcm" + } else { + flacEncoder = encoder + log.Printf("FLAC encoder for %s: %dHz/%dbit/%dch", c.Name(), r.config.SampleRate, flacBitDepth, r.config.Channels) + } + } else { + log.Printf("No FLAC encoder factory for %s, falling back to PCM", c.Name()) + codec = "pcm" + } + } + + // Create buffer tracker from client's advertised buffer_capacity. + bufferCapacity := 1048576 // 1MB default + if c.capabilities != nil && c.capabilities.BufferCapacity > 0 { + bufferCapacity = c.capabilities.BufferCapacity + } + + c.mu.Lock() + c.codec = codec + c.opusEncoder = opusEncoder + c.flacEncoder = flacEncoder + c.resampler = resampler + c.bufferTracker = NewBufferTracker(bufferCapacity) + c.mu.Unlock() + + log.Printf("Added client %s with codec %s", c.Name(), codec) + + streamSampleRate := r.config.SampleRate + streamBitDepth := r.config.BitDepth + if codec == "opus" { + streamSampleRate = 48000 + streamBitDepth = 16 + } else if codec == "flac" { + streamBitDepth = flacBitDepth + } + + var codecHeaderB64 string + if codec == "flac" && flacEncoder != nil { + codecHeaderB64 = base64.StdEncoding.EncodeToString(flacEncoder.CodecHeader()) + } + + streamStart := protocol.StreamStart{ + Player: &protocol.StreamStartPlayer{ + Codec: codec, + SampleRate: streamSampleRate, + Channels: r.config.Channels, + BitDepth: streamBitDepth, + CodecHeader: codecHeaderB64, + }, + } + + if err := c.Send("stream/start", streamStart); err != nil { + log.Printf("Error sending stream/start to %s: %v", c.Name(), err) + } +} + +func (r *PlayerGroupRole) OnClientLeave(id string, name string) {} diff --git a/third_party/sendspin-go/pkg/sendspin/role_player_test.go b/third_party/sendspin-go/pkg/sendspin/role_player_test.go new file mode 100644 index 0000000..57f0c26 --- /dev/null +++ b/third_party/sendspin-go/pkg/sendspin/role_player_test.go @@ -0,0 +1,70 @@ +// ABOUTME: Tests for the PlayerGroupRole implementation +// ABOUTME: Verifies codec negotiation, encoder setup, and stream/start on join +package sendspin + +import ( + "testing" + + "github.com/Sendspin/sendspin-go/pkg/protocol" +) + +func TestPlayerRole_OnClientJoinSendsStreamStart(t *testing.T) { + role := NewPlayerRole(PlayerRoleConfig{ + SampleRate: 48000, + Channels: 2, + BitDepth: 24, + }) + + sc := &ServerClient{ + id: "c1", + roles: []string{"player@v1"}, + capabilities: &protocol.PlayerV1Support{ + SupportedFormats: []protocol.AudioFormat{ + {Codec: "pcm", SampleRate: 48000, Channels: 2, BitDepth: 24}, + }, + }, + sendChan: make(chan interface{}, 10), + } + + role.OnClientJoin(sc) + + select { + case <-sc.sendChan: + // stream/start was sent + default: + t.Fatal("OnClientJoin did not send stream/start") + } + + if sc.Codec() != "pcm" { + t.Errorf("client codec = %q, want %q", sc.Codec(), "pcm") + } +} + +func TestPlayerRole_OnClientJoinSkipsNonPlayerClient(t *testing.T) { + role := NewPlayerRole(PlayerRoleConfig{ + SampleRate: 48000, + Channels: 2, + BitDepth: 24, + }) + + sc := &ServerClient{ + id: "c1", + roles: []string{"controller@v1"}, + sendChan: make(chan interface{}, 10), + } + + role.OnClientJoin(sc) + + select { + case <-sc.sendChan: + t.Error("should not send stream/start to non-player client") + default: + } +} + +func TestPlayerRole_Role(t *testing.T) { + role := NewPlayerRole(PlayerRoleConfig{}) + if role.Role() != "player" { + t.Errorf("Role() = %q, want %q", role.Role(), "player") + } +} diff --git a/third_party/sendspin-go/pkg/sendspin/scheduler.go b/third_party/sendspin-go/pkg/sendspin/scheduler.go new file mode 100644 index 0000000..31d2d97 --- /dev/null +++ b/third_party/sendspin-go/pkg/sendspin/scheduler.go @@ -0,0 +1,229 @@ +// ABOUTME: Timestamp-based playback scheduler for pkg/sendspin +// ABOUTME: Schedules audio buffers for precise playback timing +package sendspin + +import ( + "container/heap" + "context" + "log" + gosync "sync" + "sync/atomic" + "time" + + "github.com/Sendspin/sendspin-go/pkg/audio" + "github.com/Sendspin/sendspin-go/pkg/sync" +) + +type Scheduler struct { + clockSync *sync.ClockSync + bufferQ *BufferQueue + bufferMu gosync.Mutex // Protects bufferQ and buffering + output chan audio.Buffer + jitterMs int + staticDelay time.Duration // shifts scheduled play time forward to compensate for hardware latency + ctx context.Context + cancel context.CancelFunc + buffering bool + bufferTarget int // Number of chunks to buffer before starting playback + + received atomic.Int64 + played atomic.Int64 + dropped atomic.Int64 +} + +type SchedulerStats struct { + Received int64 + Played int64 + Dropped int64 +} + +// NewScheduler creates a playback scheduler. +// bufferMs controls startup buffering (ms of audio to accumulate before playback). +// staticDelayMs shifts every scheduled play time forward, compensating for +// downstream hardware latency like Bluetooth sinks or AVRs. Zero means no shift. +func NewScheduler(clockSync *sync.ClockSync, bufferMs int, staticDelayMs int) *Scheduler { + ctx, cancel := context.WithCancel(context.Background()) + + // Calculate buffer target from user config (bufferMs / ChunkDurationMs) + bufferTarget := bufferMs / ChunkDurationMs + if bufferTarget < 1 { + bufferTarget = 1 // Minimum 1 chunk + } + + return &Scheduler{ + clockSync: clockSync, + bufferQ: NewBufferQueue(), + output: make(chan audio.Buffer, 10), + jitterMs: bufferMs, // Store for potential future use + staticDelay: time.Duration(staticDelayMs) * time.Millisecond, + ctx: ctx, + cancel: cancel, + buffering: true, + bufferTarget: bufferTarget, + } +} + +func (s *Scheduler) Schedule(buf audio.Buffer) { + buf.PlayAt = s.clockSync.ServerToLocalTime(buf.Timestamp).Add(s.staticDelay) + + received := s.received.Add(1) - 1 + + // Sanity logs for first 5 chunks showing timing + if received < 5 { + serverNow := s.clockSync.ServerMicrosNow() + diff := buf.Timestamp - serverNow + rtt, quality := s.clockSync.GetStats() + + log.Printf("Chunk #%d: timestamp=%dµs, serverNow=%dµs, diff=%dµs (%.1fms), rtt=%dµs, quality=%v", + received, buf.Timestamp, serverNow, diff, float64(diff)/1000.0, rtt, quality) + } + + s.bufferMu.Lock() + heap.Push(s.bufferQ, buf) + s.bufferMu.Unlock() +} + +func (s *Scheduler) Run() { + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-s.ctx.Done(): + return + case <-ticker.C: + s.processQueue() + } + } +} + +func (s *Scheduler) processQueue() { + s.bufferMu.Lock() + + if s.buffering { + if s.bufferQ.Len() >= s.bufferTarget { + log.Printf("Startup buffering complete: %d chunks ready", s.bufferQ.Len()) + s.buffering = false + } else { + s.bufferMu.Unlock() + return + } + } + + now := time.Now() + + for s.bufferQ.Len() > 0 { + buf := s.bufferQ.Peek() + + delay := buf.PlayAt.Sub(now) + + if delay > 50*time.Millisecond { + // Too early — wait for next tick + break + } else if delay < -50*time.Millisecond { + // More than 50ms late: drop rather than play out of sync + heap.Pop(s.bufferQ) + s.dropped.Add(1) + log.Printf("Dropped late buffer: %v late", -delay) + } else { + // Within ±50ms window: ready to play + heap.Pop(s.bufferQ) + // Unlock before sending to avoid blocking while holding lock + s.bufferMu.Unlock() + + select { + case s.output <- buf: + s.played.Add(1) + case <-s.ctx.Done(): + return + } + + s.bufferMu.Lock() + } + } + + s.bufferMu.Unlock() +} + +func (s *Scheduler) Output() <-chan audio.Buffer { + return s.output +} + +func (s *Scheduler) Stats() SchedulerStats { + return SchedulerStats{ + Received: s.received.Load(), + Played: s.played.Load(), + Dropped: s.dropped.Load(), + } +} + +// BufferDepth returns the current buffer queue depth in milliseconds +func (s *Scheduler) BufferDepth() int { + s.bufferMu.Lock() + depth := s.bufferQ.Len() * ChunkDurationMs + s.bufferMu.Unlock() + return depth +} + +func (s *Scheduler) Stop() { + s.cancel() +} + +// Clear clears all buffered audio (used for seek operations) +func (s *Scheduler) Clear() { + s.bufferMu.Lock() + defer s.bufferMu.Unlock() + s.bufferQ = NewBufferQueue() + s.buffering = true + log.Printf("Scheduler buffers cleared, re-entering buffering mode") +} + +type BufferQueue struct { + items []audio.Buffer +} + +func NewBufferQueue() *BufferQueue { + q := &BufferQueue{} + heap.Init(q) + return q +} + +// Implement heap.Interface +func (q *BufferQueue) Len() int { return len(q.items) } + +func (q *BufferQueue) Less(i, j int) bool { + // Bounds check to prevent crashes + if i >= len(q.items) || j >= len(q.items) { + return false + } + return q.items[i].PlayAt.Before(q.items[j].PlayAt) +} + +func (q *BufferQueue) Swap(i, j int) { + // Bounds check to prevent crashes + if i >= len(q.items) || j >= len(q.items) { + return + } + q.items[i], q.items[j] = q.items[j], q.items[i] +} + +func (q *BufferQueue) Push(x interface{}) { + q.items = append(q.items, x.(audio.Buffer)) +} + +func (q *BufferQueue) Pop() interface{} { + n := len(q.items) + if n == 0 { + return audio.Buffer{} + } + item := q.items[n-1] + q.items = q.items[:n-1] + return item +} + +func (q *BufferQueue) Peek() audio.Buffer { + if len(q.items) == 0 { + return audio.Buffer{} + } + return q.items[0] +} diff --git a/third_party/sendspin-go/pkg/sendspin/scheduler_test.go b/third_party/sendspin-go/pkg/sendspin/scheduler_test.go new file mode 100644 index 0000000..598a5f3 --- /dev/null +++ b/third_party/sendspin-go/pkg/sendspin/scheduler_test.go @@ -0,0 +1,70 @@ +// ABOUTME: Tests for the playback scheduler +// ABOUTME: Covers static-delay offset, buffer ordering, and drop semantics +package sendspin + +import ( + "testing" + "time" + + "github.com/Sendspin/sendspin-go/pkg/audio" + "github.com/Sendspin/sendspin-go/pkg/sync" +) + +// TestScheduler_StaticDelayShift is a regression guard for the +// --static-delay-ms feature. With an unsynced ClockSync, +// ServerToLocalTime returns time.Unix(0, serverTime*1000) deterministically, +// which gives us an exact expected PlayAt we can check. +// +// For a 250ms static delay, a buffer whose timestamp would otherwise play +// at local time T should actually play at T + 250ms. +func TestScheduler_StaticDelayShift(t *testing.T) { + const serverTimestampUs = int64(1_000_000_000) // 1000 seconds, arbitrary + const bufferMs = 200 + + cases := []struct { + name string + staticDelayMs int + }{ + {"no delay", 0}, + {"250ms delay", 250}, + {"1000ms delay", 1000}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cs := sync.NewClockSync() + sched := NewScheduler(cs, bufferMs, tc.staticDelayMs) + defer sched.Stop() + + buf := audio.Buffer{Timestamp: serverTimestampUs} + sched.Schedule(buf) + + // Schedule mutates a local copy; inspect the queue. + sched.bufferMu.Lock() + queued := sched.bufferQ.Peek() + sched.bufferMu.Unlock() + + baseline := time.Unix(0, serverTimestampUs*1000) + want := baseline.Add(time.Duration(tc.staticDelayMs) * time.Millisecond) + + if !queued.PlayAt.Equal(want) { + t.Errorf("PlayAt = %v, want %v (static delay = %dms)", + queued.PlayAt, want, tc.staticDelayMs) + } + }) + } +} + +// TestScheduler_StaticDelayDefaultZero confirms that a scheduler built with +// the old two-argument shape would still behave correctly — i.e. the delay +// field defaults cleanly to 0. This is a belt-and-suspenders check against +// future refactors that might forget to thread the parameter through. +func TestScheduler_StaticDelayDefaultZero(t *testing.T) { + cs := sync.NewClockSync() + sched := NewScheduler(cs, 200, 0) + defer sched.Stop() + + if sched.staticDelay != 0 { + t.Errorf("staticDelay with 0 arg = %v, want 0", sched.staticDelay) + } +} diff --git a/third_party/sendspin-go/pkg/sendspin/server.go b/third_party/sendspin-go/pkg/sendspin/server.go new file mode 100644 index 0000000..06fe810 --- /dev/null +++ b/third_party/sendspin-go/pkg/sendspin/server.go @@ -0,0 +1,647 @@ +// ABOUTME: High-level Server API for Sendspin streaming +// ABOUTME: Wraps server components into a simple, user-friendly interface +package sendspin + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net" + "net/http" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/Sendspin/sendspin-go/internal/discovery" + "github.com/Sendspin/sendspin-go/internal/server" + "github.com/Sendspin/sendspin-go/pkg/protocol" + "github.com/google/uuid" + "github.com/gorilla/websocket" +) + +const ( + ProtocolVersion = 1 + + // Binary message type IDs per spec (bits 7-2 for role, bits 1-0 for slot) + // Player role: 000001xx (4-7), slot 0 = 4 + AudioChunkMessageType = 4 + + DefaultSampleRate = 192000 + DefaultChannels = 2 + DefaultBitDepth = 24 + + ChunkDurationMs = 20 // 20ms chunks + BufferAheadMs = 500 // Send audio 500ms ahead +) + +type ServerConfig struct { + // Port to listen on (default: 8927) + Port int + + Name string + + // Audio source to stream (required) + Source AudioSource + + // EnableMDNS enables mDNS service advertisement (default: true) + EnableMDNS bool + + Debug bool + + // DiscoverClients enables server-initiated discovery: browse for + // clients advertising _sendspin._tcp and dial out to them. + // See https://www.sendspin-audio.com/spec/ — "server-initiated" mode. + DiscoverClients bool + + // SupportedRoles lists the role families this server activates. + // When nil, defaults to ["player", "metadata"] for backward compat. + // Roles registered via Group.RegisterRole are also activated + // regardless of this list. + SupportedRoles []string + + // ClientFilter, if non-nil, is invoked after a client's hello has + // been parsed and before the server/hello reply is sent. Returning + // false causes the server to close the connection without joining + // the client to the default group. Used by multi-preset servers to + // admit only the speakers belonging to the currently active preset. + ClientFilter func(clientID string) bool + + // OnClientHello, if non-nil, is invoked for every parsed + // client/hello, regardless of whether ClientFilter accepts the + // connection. Lets the host application maintain a roster of + // observed (clientID, name) pairs — useful when filtering rejects + // most connections but the operator still wants to discover which + // speakers exist on the network and what their stable IDs are. + OnClientHello func(clientID, name string) +} + +type Server struct { + config ServerConfig + serverID string + + upgrader websocket.Upgrader + + httpServer *http.Server + mux *http.ServeMux + boundAddr atomic.Pointer[net.TCPAddr] + + clients map[string]*ServerClient + clientsMu sync.RWMutex + defaultGroup *Group + + clockStart time.Time // monotonic microseconds origin + + audioSource AudioSource + consecutiveReadErrs int + + mdnsManager *discovery.Manager + + // server-initiated discovery dialer cancel + dialerCancel context.CancelFunc + + stopChan chan struct{} + stopOnce sync.Once + shutdownMu sync.RWMutex + isShutdown bool + wg sync.WaitGroup +} + +type ClientInfo struct { + ID string + Name string + State string + Volume int + Muted bool + Codec string +} + +func NewServer(config ServerConfig) (*Server, error) { + // Port == 0 is honored as "let the OS pick an ephemeral port". The + // CLI / config-file layers supply 8927 as the documented default + // before reaching this point; library callers that omit Port get + // ephemeral binding (and can read it back via Server.Addr after + // Start). This is the substitution-free behavior tests rely on for + // flake-free Port: 0 binding. + if config.Name == "" { + config.Name = "Sendspin Server" + } + if config.Source == nil { + return nil, fmt.Errorf("audio source is required") + } + if config.SupportedRoles == nil { + config.SupportedRoles = []string{"player", "metadata"} + } + + mux := http.NewServeMux() + + s := &Server{ + config: config, + serverID: uuid.New().String(), + mux: mux, + audioSource: config.Source, + upgrader: websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + // TODO: For production, implement proper origin validation. + // Currently permissive for local-network deployments. + return true + }, + }, + clients: make(map[string]*ServerClient), + clockStart: time.Now(), + stopChan: make(chan struct{}), + } + + s.defaultGroup = NewGroup(s.serverID) + + s.defaultGroup.RegisterRole(NewMetadataRole(MetadataConfig{ + GetMetadata: func() (string, string, string) { + return s.audioSource.Metadata() + }, + ClockMicros: s.getClockMicros, + })) + + s.defaultGroup.RegisterRole(NewPlayerRole(PlayerRoleConfig{ + SampleRate: s.audioSource.SampleRate(), + Channels: s.audioSource.Channels(), + BitDepth: DefaultBitDepth, + NewEncoder: func(sampleRate, channels, chunkSamples int) (*server.OpusEncoder, error) { + return server.NewOpusEncoder(sampleRate, channels, chunkSamples) + }, + NewFLACEncoder: func(sampleRate, channels, bitDepth, blockSize int) (*server.FLACEncoder, error) { + return server.NewFLACEncoder(sampleRate, channels, bitDepth, blockSize) + }, + })) + + return s, nil +} + +func (s *Server) Start() error { + log.Printf("Server starting: %s (ID: %s)", s.config.Name, s.serverID) + log.Printf("Audio source: %dHz/%dbit/%dch", + s.audioSource.SampleRate(), + DefaultBitDepth, + s.audioSource.Channels()) + + if s.config.EnableMDNS { + s.mdnsManager = discovery.NewManager(discovery.Config{ + ServiceName: s.config.Name, + Port: s.config.Port, + ServerMode: true, + }) + + if err := s.mdnsManager.Advertise(); err != nil { + log.Printf("Failed to start mDNS advertisement: %v", err) + } else { + log.Printf("mDNS advertisement started") + } + } + + if s.config.DiscoverClients { + if s.mdnsManager == nil { + // If mDNS isn't running for advertising, start a manager just for browsing. + s.mdnsManager = discovery.NewManager(discovery.Config{ + ServiceName: s.config.Name, + Port: s.config.Port, + ServerMode: true, + }) + } + + if err := s.mdnsManager.BrowseClients(); err != nil { + log.Printf("Failed to start client discovery: %v", err) + } else { + log.Printf("Browsing for clients advertising _sendspin._tcp") + + dialCtx, cancel := context.WithCancel(context.Background()) + s.dialerCancel = cancel + + dialer := newClientDialer(s.mdnsManager.Clients(), func(ctx context.Context, info *discovery.ClientInfo) error { + return dialAndHandle(ctx, info, s.handleConnection) + }) + + s.wg.Add(1) + go func() { + defer s.wg.Done() + dialer.run(dialCtx) + }() + } + } + + s.mux.HandleFunc("/sendspin", s.handleWebSocket) + + s.wg.Add(1) + go func() { + defer s.wg.Done() + s.streamAudio() + }() + + addr := fmt.Sprintf(":%d", s.config.Port) + + listener, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("listen on %s: %w", addr, err) + } + tcpAddr, _ := listener.Addr().(*net.TCPAddr) + s.boundAddr.Store(tcpAddr) + log.Printf("WebSocket server listening on %s", listener.Addr()) + + s.httpServer = &http.Server{ + Handler: s.mux, + } + + errChan := make(chan error, 1) + go func() { + if err := s.httpServer.Serve(listener); err != http.ErrServerClosed { + errChan <- err + } + }() + + select { + case <-s.stopChan: + log.Printf("Server shutting down...") + case err := <-errChan: + log.Printf("HTTP server error: %v", err) + return err + } + + s.shutdownMu.Lock() + s.isShutdown = true + s.shutdownMu.Unlock() + + // Cancel in-flight client dials before stopping mDNS so they observe + // context cancellation ahead of the discovery channel closing. + if s.dialerCancel != nil { + s.dialerCancel() + } + + 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) + } + + if err := s.audioSource.Close(); err != nil { + log.Printf("Error closing audio source: %v", err) + } + + if s.defaultGroup != nil { + s.defaultGroup.Close() + } + + s.wg.Wait() + log.Printf("Server stopped cleanly") + + return nil +} + +func (s *Server) Stop() { + s.stopOnce.Do(func() { + close(s.stopChan) + }) +} + +// getClockMicros returns server uptime in microseconds (monotonic, not wall clock). +func (s *Server) getClockMicros() int64 { + return time.Since(s.clockStart).Microseconds() +} + +// Group returns the server's default playback group. For M2 there is +// exactly one implicit group per Server; the accessor exists so future +// GroupRole implementations (M3) can subscribe to its event bus. +func (s *Server) Group() *Group { + return s.defaultGroup +} + +// Addr returns the network address the server is listening on, or nil if +// Start has not yet bound a listener. Useful in tests that configure +// Port: 0 (OS-assigned ephemeral port) and need to know the actual port +// after Start runs. +func (s *Server) Addr() net.Addr { + if a := s.boundAddr.Load(); a != nil { + return a + } + return nil +} + +// DisconnectClient closes the WebSocket for the given client_id, causing +// the per-client read loop to exit and standard teardown to run. Returns +// true if a matching client was found. +func (s *Server) DisconnectClient(id string) bool { + s.clientsMu.RLock() + c, ok := s.clients[id] + s.clientsMu.RUnlock() + if !ok { + return false + } + // Closing the underlying connection unblocks the conn.ReadMessage + // loop in handleConnection, which then runs removeClient via defer. + _ = c.conn.Close() + return true +} + +// NotifyStreamStartAll re-runs the PlayerGroupRole join hook for every +// connected client. Use after Resume() to send a fresh stream/start +// (and recreate encoders) when transitioning paused → playing. +func (s *Server) NotifyStreamStartAll() { + if s.defaultGroup == nil { + return + } + s.clientsMu.RLock() + clients := make([]*ServerClient, 0, len(s.clients)) + for _, c := range s.clients { + clients = append(clients, c) + } + s.clientsMu.RUnlock() + + s.defaultGroup.mu.RLock() + roles := make([]GroupRole, 0, len(s.defaultGroup.roles)) + for _, r := range s.defaultGroup.roles { + roles = append(roles, r) + } + s.defaultGroup.mu.RUnlock() + + for _, c := range clients { + for _, r := range roles { + r.OnClientJoin(c) + } + } +} + +// NotifyStreamEndAll sends stream/end to every connected player client. +// Use when transitioning playing → paused so clients flush buffers and +// stop emitting audio while the WebSocket stays open. +func (s *Server) NotifyStreamEndAll() { + s.notifyStreamEnd() +} + +func (s *Server) Clients() []ClientInfo { + s.clientsMu.RLock() + defer s.clientsMu.RUnlock() + + clients := make([]ClientInfo, 0, len(s.clients)) + for _, c := range s.clients { + clients = append(clients, ClientInfo{ + ID: c.ID(), + Name: c.Name(), + State: c.State(), + Volume: c.Volume(), + Muted: c.Muted(), + Codec: c.Codec(), + }) + } + + return clients +} + +// ErrClientFiltered is returned by handleConnection when ClientFilter +// rejected the client_id from client/hello. The server-initiated dialer +// uses this sentinel to distinguish "filter rejection — try again +// later" from a successfully-handled session that should be latched. +var ErrClientFiltered = fmt.Errorf("client rejected by ClientFilter") + +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) error { + defer conn.Close() + conn.SetReadLimit(1 << 20) // 1MB + + s.shutdownMu.RLock() + if s.isShutdown { + s.shutdownMu.RUnlock() + log.Printf("Rejecting connection during shutdown") + return nil + } + s.shutdownMu.RUnlock() + + _, data, err := conn.ReadMessage() + if err != nil { + log.Printf("Error reading hello: %v", err) + return nil + } + + var msg protocol.Message + if err := json.Unmarshal(data, &msg); err != nil { + log.Printf("Error unmarshaling message: %v", err) + return nil + } + + if msg.Type != "client/hello" { + log.Printf("Expected client/hello, got %s", msg.Type) + return nil + } + + helloData, err := json.Marshal(msg.Payload) + if err != nil { + log.Printf("Error marshaling hello payload: %v", err) + return nil + } + + var hello protocol.ClientHello + if err := json.Unmarshal(helloData, &hello); err != nil { + log.Printf("Error unmarshaling client hello: %v", err) + return nil + } + + if hello.ClientID == "" || hello.Name == "" { + log.Printf("Client hello missing required fields") + return nil + } + if len(hello.ClientID) > 256 || len(hello.Name) > 256 || len(hello.SupportedRoles) > 20 { + log.Printf("Client hello fields exceed size limits") + return nil + } + + log.Printf("Client hello: %s (ID: %s, Roles: %v)", hello.Name, hello.ClientID, hello.SupportedRoles) + + if s.config.OnClientHello != nil { + s.config.OnClientHello(hello.ClientID, hello.Name) + } + + if s.config.ClientFilter != nil && !s.config.ClientFilter(hello.ClientID) { + log.Printf("Rejecting client %s (filtered out by ClientFilter)", hello.ClientID) + return ErrClientFiltered + } + + c := &ServerClient{ + id: hello.ClientID, + name: hello.Name, + conn: conn, + roles: hello.SupportedRoles, + capabilities: hello.PlayerV1Support, + state: "synchronized", + volume: 100, + muted: false, + sendChan: make(chan interface{}, 100), + done: make(chan struct{}), + } + + s.clientsMu.Lock() + if _, exists := s.clients[hello.ClientID]; exists { + s.clientsMu.Unlock() + log.Printf("Client ID %s already connected, rejecting duplicate", hello.ClientID) + return nil + } + s.clients[c.id] = c + s.clientsMu.Unlock() + + defer func() { + s.removeClient(c) + log.Printf("Client disconnected: %s", c.name) + }() + + activeRoles := s.activateRoles(hello.SupportedRoles) + serverHello := protocol.ServerHello{ + ServerID: s.serverID, + Name: s.config.Name, + Version: ProtocolVersion, + ActiveRoles: activeRoles, + ConnectionReason: "playback", + } + + if err := c.Send("server/hello", serverHello); err != nil { + log.Printf("Error sending server hello: %v", err) + return nil + } + + s.wg.Add(1) + go func() { + defer s.wg.Done() + s.clientWriter(c) + }() + + // Add to group AFTER server/hello and writer start so that: + // 1. server/hello is the first message the client receives + // 2. The writer goroutine is running to deliver group/update + // (from addClient) and role-dispatched messages (stream/start, + // server/state) via sendChan. + s.defaultGroup.addClient(c) + + 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(c, data) + } + return nil +} + +func (s *Server) clientWriter(c *ServerClient) { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + const writeDeadline = 10 * time.Second + + for { + select { + case msg := <-c.sendChan: + switch v := msg.(type) { + case []byte: + c.conn.SetWriteDeadline(time.Now().Add(writeDeadline)) + if err := c.conn.WriteMessage(websocket.BinaryMessage, v); err != nil { + return + } + default: + data, err := json.Marshal(v) + if err != nil { + continue + } + c.conn.SetWriteDeadline(time.Now().Add(writeDeadline)) + if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil { + return + } + } + + case <-ticker.C: + if err := c.conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(10*time.Second)); err != nil { + return + } + + case <-c.done: + return + } + } +} + +func (s *Server) removeClient(c *ServerClient) { + c.mu.Lock() + if c.opusEncoder != nil { + c.opusEncoder.Close() + c.opusEncoder = nil + } + if c.flacEncoder != nil { + c.flacEncoder.Close() + c.flacEncoder = nil + } + c.resampler = nil + c.mu.Unlock() + + s.clientsMu.Lock() + delete(s.clients, c.id) + s.clientsMu.Unlock() + + s.defaultGroup.removeClient(c) + + close(c.done) +} + +// activateRoles filters a client's advertised role list down to the roles +// this server supports (via config + registered GroupRoles), keeping only +// the first version of each role family so "player@v1" wins over a later +// "player@v2" entry in the same hello. +func (s *Server) activateRoles(supportedRoles []string) []string { + // Build the set of role families this server supports. + allowed := make(map[string]bool) + for _, family := range s.config.SupportedRoles { + allowed[family] = true + } + + // Roles registered on the Group are also allowed. + if s.defaultGroup != nil { + s.defaultGroup.mu.RLock() + for family := range s.defaultGroup.roles { + allowed[family] = true + } + s.defaultGroup.mu.RUnlock() + } + + 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 + } + + if allowed[family] { + seen[family] = true + result = append(result, role) + } + } + + return result +} diff --git a/third_party/sendspin-go/pkg/sendspin/server_client.go b/third_party/sendspin-go/pkg/sendspin/server_client.go new file mode 100644 index 0000000..536416d --- /dev/null +++ b/third_party/sendspin-go/pkg/sendspin/server_client.go @@ -0,0 +1,240 @@ +// ABOUTME: ServerClient represents one accepted connection on a sendspin Server +// ABOUTME: Exposes ID/Name/Roles/Send surface for the Group/GroupRole layer +package sendspin + +import ( + "encoding/json" + "fmt" + "log" + "strings" + "sync" + "time" + + "github.com/Sendspin/sendspin-go/internal/server" + "github.com/Sendspin/sendspin-go/pkg/audio" + "github.com/Sendspin/sendspin-go/pkg/protocol" + "github.com/gorilla/websocket" +) + +// ServerClient represents one accepted WebSocket connection on a Server. +// It owns the client's negotiated roles, capabilities, playback state, +// per-codec encoder state, and the outbound send channel. +// +// Exposed accessors (ID, Name, Roles, HasRole, Send, SendBinary) are the +// stable surface the future Group/GroupRole layer depends on. Internal +// fields remain unexported so the server package can mutate them through +// the mutex without leaking that detail to callers. +type ServerClient struct { + id string + name string + conn *websocket.Conn + roles []string + capabilities *protocol.PlayerV1Support + + state string + volume int + muted bool + + codec string + opusEncoder *server.OpusEncoder + flacEncoder *server.FLACEncoder + resampler *audio.Resampler // non-nil only when source rate != 48kHz + bufferTracker *BufferTracker + + sendChan chan interface{} + done chan struct{} + + mu sync.RWMutex +} + +// ID returns the client-supplied unique identifier from client/hello. +func (c *ServerClient) ID() string { return c.id } + +// Name returns the human-friendly name from client/hello. +func (c *ServerClient) Name() string { return c.name } + +// Roles returns the client's advertised role list as a fresh slice. +// Callers may mutate the returned slice without affecting the ServerClient. +func (c *ServerClient) Roles() []string { + out := make([]string, len(c.roles)) + copy(out, c.roles) + return out +} + +// HasRole reports whether this client advertised a given role family. +// It matches both exact ("player") and versioned ("player@v1") forms so +// callers can query by family without tracking versions. +func (c *ServerClient) HasRole(role string) bool { + for _, r := range c.roles { + if r == role || strings.HasPrefix(r, role+"@") { + return true + } + } + return false +} + +// Send enqueues a typed control message for transmission. Returns an +// error immediately if the client's send buffer is full rather than +// blocking — callers decide whether to drop or disconnect. +// +// A nil return means the message was enqueued, not that it was delivered: +// after the client's writer goroutine has exited (e.g., post-disconnect), +// enqueued messages are dropped silently. Callers should treat this as +// best-effort once they've observed a client leaving. +func (c *ServerClient) Send(msgType string, payload interface{}) error { + msg := protocol.Message{ + Type: msgType, + Payload: payload, + } + select { + case c.sendChan <- msg: + return nil + default: + return fmt.Errorf("client send buffer full") + } +} + +// SendBinary enqueues a raw binary frame (e.g., an audio chunk) for +// transmission. Same non-blocking semantics as Send. +// +// A nil return means the frame was enqueued, not that it was delivered: +// after the client's writer goroutine has exited (e.g., post-disconnect), +// enqueued frames are dropped silently. Callers should treat this as +// best-effort once they've observed a client leaving. +func (c *ServerClient) SendBinary(data []byte) error { + select { + case c.sendChan <- data: + return nil + default: + return fmt.Errorf("client send buffer full (depth=%d, cap=%d)", + len(c.sendChan), cap(c.sendChan)) + } +} + +// State returns the client's current playback state ("synchronized", +// "playing", "paused"). Safe to call concurrently with state updates. +func (c *ServerClient) State() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.state +} + +// Volume returns the client's reported volume (0-100). Safe to call +// concurrently with state updates. +func (c *ServerClient) Volume() int { + c.mu.RLock() + defer c.mu.RUnlock() + return c.volume +} + +// Muted returns the client's mute state. Safe to call concurrently with +// state updates. +func (c *ServerClient) Muted() bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.muted +} + +// Codec returns the currently-negotiated codec name ("pcm", "opus", ""). +// Returns the empty string before stream negotiation completes. Safe to +// call concurrently with state updates. +func (c *ServerClient) Codec() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.codec +} + +// NewServerClientFromConn wraps an existing WebSocket connection in a +// ServerClient and starts a background writer goroutine. This is the +// entry point for code that accepts its own WebSocket connections +// (e.g., conformance adapters) and wants to use the typed Send/SendBinary +// API without constructing a full Server. +// +// The caller is responsible for reading from the connection; the writer +// goroutine handles outbound messages via Send/SendBinary. Call Close() +// when done to stop the writer and release resources. +func NewServerClientFromConn(conn *websocket.Conn, id, name string, roles []string, capabilities *protocol.PlayerV1Support) *ServerClient { + c := &ServerClient{ + id: id, + name: name, + conn: conn, + roles: roles, + capabilities: capabilities, + state: "synchronized", + volume: 100, + sendChan: make(chan interface{}, 100), + done: make(chan struct{}), + } + go c.runWriter() + return c +} + +// runWriter drains the sendChan and writes messages to the WebSocket. +// Exits when the done channel is closed (via Close). This is the +// standalone equivalent of Server.clientWriter for ServerClients +// created via NewServerClientFromConn. +func (c *ServerClient) runWriter() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + const writeDeadline = 10 * time.Second + + exit := func(reason string, err error) { + log.Printf("runWriter %s exiting: %s: %v (queue depth %d)", + c.name, reason, err, len(c.sendChan)) + } + + for { + select { + case msg := <-c.sendChan: + queueDepth := len(c.sendChan) + switch v := msg.(type) { + case []byte: + c.conn.SetWriteDeadline(time.Now().Add(writeDeadline)) + wStart := time.Now() + if err := c.conn.WriteMessage(websocket.BinaryMessage, v); err != nil { + exit("binary write", err) + return + } + wDur := time.Since(wStart) + if wDur > 5*time.Millisecond || queueDepth > 5 { + log.Printf("ws write %s: %s for %d bytes (queue depth %d)", + c.name, wDur.Round(time.Microsecond), len(v), queueDepth) + } + default: + data, err := json.Marshal(v) + if err != nil { + continue + } + c.conn.SetWriteDeadline(time.Now().Add(writeDeadline)) + if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil { + exit("text write", err) + return + } + } + + case <-ticker.C: + if err := c.conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(10*time.Second)); err != nil { + exit("ping", err) + return + } + + case <-c.done: + return + } + } +} + +// Close stops the writer goroutine and signals that this ServerClient +// is done. Safe to call multiple times. Does NOT close the underlying +// WebSocket connection — the caller owns that lifecycle. +func (c *ServerClient) Close() { + c.mu.Lock() + defer c.mu.Unlock() + select { + case <-c.done: + // Already closed + default: + close(c.done) + } +} diff --git a/third_party/sendspin-go/pkg/sendspin/server_client_test.go b/third_party/sendspin-go/pkg/sendspin/server_client_test.go new file mode 100644 index 0000000..e83a8f0 --- /dev/null +++ b/third_party/sendspin-go/pkg/sendspin/server_client_test.go @@ -0,0 +1,227 @@ +// ABOUTME: Tests for the ServerClient exported accessor surface +// ABOUTME: Guards the shape the Group/GroupRole layer will build on in M2+ +package sendspin + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Sendspin/sendspin-go/pkg/protocol" + "github.com/gorilla/websocket" +) + +// TestServerClient_Accessors confirms that the six exported accessors on +// ServerClient return the values the struct was constructed with. These are +// the methods the future Group/GroupRole layer will depend on, so their +// shape is load-bearing — adding a test now pins it. +func TestServerClient_Accessors(t *testing.T) { + sc := &ServerClient{ + id: "client-abc", + name: "Living Room", + roles: []string{"player@v1", "metadata@v1"}, + } + + if got := sc.ID(); got != "client-abc" { + t.Errorf("ID() = %q, want %q", got, "client-abc") + } + if got := sc.Name(); got != "Living Room" { + t.Errorf("Name() = %q, want %q", got, "Living Room") + } + roles := sc.Roles() + if len(roles) != 2 || roles[0] != "player@v1" || roles[1] != "metadata@v1" { + t.Errorf("Roles() = %v, want [player@v1 metadata@v1]", roles) + } +} + +// TestServerClient_RolesReturnsCopy guards the defensive-copy contract. +// Mutating the returned slice must not affect a subsequent Roles() call. +func TestServerClient_RolesReturnsCopy(t *testing.T) { + sc := &ServerClient{roles: []string{"player@v1", "metadata@v1"}} + + first := sc.Roles() + first[0] = "tampered" + + second := sc.Roles() + if second[0] != "player@v1" { + t.Errorf("Roles() returned aliased slice: got %q after mutation, want %q", second[0], "player@v1") + } +} + +// TestServerClient_HasRole covers both exact matches and versioned matches +// (e.g., "player" should match "player@v1"). This mirrors the existing +// Server.hasRole behavior, which the accessor replaces. +func TestServerClient_HasRole(t *testing.T) { + sc := &ServerClient{roles: []string{"player@v1", "metadata@v1"}} + + cases := []struct { + role string + want bool + }{ + {"player", true}, + {"player@v1", true}, + {"metadata", true}, + {"controller", false}, + {"artwork", false}, + } + for _, tc := range cases { + if got := sc.HasRole(tc.role); got != tc.want { + t.Errorf("HasRole(%q) = %v, want %v", tc.role, got, tc.want) + } + } +} + +// TestServerClient_SendBufferFull guards the back-pressure behavior: Send +// must not block when the buffered sendChan is full. Returning an error +// lets the caller decide whether to drop, log, or disconnect. +func TestServerClient_SendBufferFull(t *testing.T) { + sc := &ServerClient{ + sendChan: make(chan interface{}, 1), + } + + if err := sc.Send("server/state", map[string]string{"a": "b"}); err != nil { + t.Fatalf("first Send should succeed, got %v", err) + } + if err := sc.Send("server/state", map[string]string{"c": "d"}); err == nil { + t.Error("second Send to full buffer should return error, got nil") + } +} + +// TestServerClient_SendBinaryBufferFull is the same back-pressure check +// for the binary path (audio chunks go through here). +func TestServerClient_SendBinaryBufferFull(t *testing.T) { + sc := &ServerClient{ + sendChan: make(chan interface{}, 1), + } + if err := sc.SendBinary([]byte{0x01}); err != nil { + t.Fatalf("first SendBinary should succeed, got %v", err) + } + if err := sc.SendBinary([]byte{0x02}); err == nil { + t.Error("second SendBinary to full buffer should return error, got nil") + } +} + +// TestServerClient_StateAccessors confirms that State/Volume/Muted/Codec +// return the mutable playback fields under the client's RWMutex. These +// are the fields the M2 Group event bus carries in ClientStateChangedEvent, +// so their shape is load-bearing for M3's role handlers. +func TestServerClient_StateAccessors(t *testing.T) { + sc := &ServerClient{ + state: "synchronized", + volume: 72, + muted: true, + codec: "opus", + } + + if got := sc.State(); got != "synchronized" { + t.Errorf("State() = %q, want %q", got, "synchronized") + } + if got := sc.Volume(); got != 72 { + t.Errorf("Volume() = %d, want 72", got) + } + if got := sc.Muted(); got != true { + t.Errorf("Muted() = %v, want true", got) + } + if got := sc.Codec(); got != "opus" { + t.Errorf("Codec() = %q, want %q", got, "opus") + } +} + +// TestServerClient_StateAccessorsConcurrent is a light race-detector bait. +// Running Write/Read in parallel under -race should flag any missing lock. +func TestServerClient_StateAccessorsConcurrent(t *testing.T) { + sc := &ServerClient{state: "synchronized", volume: 50} + + done := make(chan struct{}) + go func() { + for i := 0; i < 1000; i++ { + sc.mu.Lock() + sc.volume = i % 100 + sc.mu.Unlock() + } + close(done) + }() + + for i := 0; i < 1000; i++ { + _ = sc.Volume() + _ = sc.State() + } + <-done +} + +// TestNewServerClientFromConn_SendAndClose confirms the constructor + +// writer + Close lifecycle works: messages enqueued via Send arrive on +// the WebSocket, and Close stops the writer without panic. +func TestNewServerClientFromConn_SendAndClose(t *testing.T) { + // Use an in-process WebSocket pair via httptest. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upgrader := websocket.Upgrader{} + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + + // Read the one message we expect. + _, data, err := conn.ReadMessage() + if err != nil { + t.Errorf("server read: %v", err) + return + } + var msg protocol.Message + if err := json.Unmarshal(data, &msg); err != nil { + t.Errorf("unmarshal: %v", err) + return + } + if msg.Type != "server/hello" { + t.Errorf("got type %q, want server/hello", msg.Type) + } + })) + defer srv.Close() + + wsURL := "ws" + srv.URL[len("http"):] + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + + sc := NewServerClientFromConn(conn, "test-id", "Test", []string{"player@v1"}, nil) + + if err := sc.Send("server/hello", map[string]string{"name": "test"}); err != nil { + t.Fatalf("Send: %v", err) + } + + // Give the writer time to flush. + time.Sleep(50 * time.Millisecond) + + sc.Close() + // Double-close should not panic. + sc.Close() +} + +// TestCreateAudioChunk confirms the exported helper produces the +// correct binary frame format. +func TestCreateAudioChunk(t *testing.T) { + chunk := CreateAudioChunk(1000000, []byte{0xAA, 0xBB}) + if chunk[0] != AudioChunkMessageType { + t.Errorf("type byte = %d, want %d", chunk[0], AudioChunkMessageType) + } + if len(chunk) != 9+2 { + t.Errorf("len = %d, want 11", len(chunk)) + } +} + +// TestCreateArtworkChunk confirms channel mapping and frame format. +func TestCreateArtworkChunk(t *testing.T) { + chunk := CreateArtworkChunk(2, 5000000, []byte{0xFF}) + expectedType := byte(protocol.ArtworkChannel0MessageType + 2) + if chunk[0] != expectedType { + t.Errorf("type byte = %d, want %d", chunk[0], expectedType) + } + if len(chunk) != protocol.BinaryMessageHeaderSize+1 { + t.Errorf("len = %d, want %d", len(chunk), protocol.BinaryMessageHeaderSize+1) + } +} diff --git a/third_party/sendspin-go/pkg/sendspin/server_dispatch.go b/third_party/sendspin-go/pkg/sendspin/server_dispatch.go new file mode 100644 index 0000000..5925ff2 --- /dev/null +++ b/third_party/sendspin-go/pkg/sendspin/server_dispatch.go @@ -0,0 +1,131 @@ +// ABOUTME: Message dispatch for incoming client control frames +// ABOUTME: Routes client/time, client/state, client/goodbye, client/command to handlers +package sendspin + +import ( + "encoding/json" + "log" + + "github.com/Sendspin/sendspin-go/pkg/protocol" +) + +func (s *Server) handleClientMessage(c *ServerClient, 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(c, msg.Payload) + case "client/state": + s.handleClientState(c, msg.Payload) + case "client/goodbye": + s.handleClientGoodbye(c, msg.Payload) + case "client/command": + s.handleClientCommand(c, msg.Payload) + default: + if s.config.Debug { + log.Printf("Unknown message type: %s", msg.Type) + } + } +} + +func (s *Server) handleTimeSync(c *ServerClient, payload interface{}) { + serverRecv := s.getClockMicros() + + timeData, err := json.Marshal(payload) + if err != nil { + return + } + + var clientTime protocol.ClientTime + if err := json.Unmarshal(timeData, &clientTime); err != nil { + return + } + + serverSend := s.getClockMicros() + + response := protocol.ServerTime{ + ClientTransmitted: clientTime.ClientTransmitted, + ServerReceived: serverRecv, + ServerTransmitted: serverSend, + } + + if err := c.Send("server/time", response); err != nil { + if s.config.Debug { + log.Printf("Error sending server/time to %s: %v", c.name, err) + } + } +} + +// handleClientState applies a client's player state update per spec. +func (s *Server) handleClientState(c *ServerClient, payload interface{}) { + stateData, err := json.Marshal(payload) + if err != nil { + return + } + + var stateMsg protocol.ClientStateMessage + if err := json.Unmarshal(stateData, &stateMsg); err != nil { + return + } + + if stateMsg.Player != nil { + c.mu.Lock() + c.state = stateMsg.Player.State + c.volume = stateMsg.Player.Volume + c.muted = stateMsg.Player.Muted + c.mu.Unlock() + + if s.config.Debug { + log.Printf("Client %s state: %s (vol: %d, muted: %v)", c.name, stateMsg.Player.State, stateMsg.Player.Volume, stateMsg.Player.Muted) + } + + s.defaultGroup.publish(ClientStateChangedEvent{ + Client: c, + State: stateMsg.Player.State, + Volume: stateMsg.Player.Volume, + Muted: stateMsg.Player.Muted, + }) + } +} + +func (s *Server) handleClientCommand(c *ServerClient, payload interface{}) { + data, err := json.Marshal(payload) + if err != nil { + log.Printf("Error marshaling client/command payload: %v", err) + return + } + + // client/command payloads are role-keyed: {"controller": {...}, "player": {...}} + var rolePayloads map[string]json.RawMessage + if err := json.Unmarshal(data, &rolePayloads); err != nil { + log.Printf("Error unmarshaling client/command: %v", err) + return + } + + for roleFamily, roleData := range rolePayloads { + if err := s.defaultGroup.RouteMessage(c, roleFamily, roleData); err != nil { + if s.config.Debug { + log.Printf("client/command routing error for role %s: %v", roleFamily, err) + } + } + } +} + +func (s *Server) handleClientGoodbye(c *ServerClient, payload interface{}) { + goodbyeData, err := json.Marshal(payload) + if err != nil { + return + } + + var goodbye protocol.ClientGoodbye + if err := json.Unmarshal(goodbyeData, &goodbye); err != nil { + return + } + + log.Printf("Client %s goodbye: %s", c.name, goodbye.Reason) + // Connection close happens in handleConnection's read loop once this returns. +} diff --git a/third_party/sendspin-go/pkg/sendspin/server_stream.go b/third_party/sendspin-go/pkg/sendspin/server_stream.go new file mode 100644 index 0000000..45d97dc --- /dev/null +++ b/third_party/sendspin-go/pkg/sendspin/server_stream.go @@ -0,0 +1,260 @@ +// ABOUTME: Audio streaming orchestration for Server +// ABOUTME: Tick-driven chunk generation, codec negotiation, per-client encode/send +package sendspin + +import ( + "encoding/binary" + "log" + "time" + + "github.com/Sendspin/sendspin-go/pkg/protocol" +) + +func (s *Server) streamAudio() { + log.Printf("Audio streaming started") + + ticker := time.NewTicker(time.Duration(ChunkDurationMs) * time.Millisecond) + defer ticker.Stop() + + tickBudget := time.Duration(ChunkDurationMs) * time.Millisecond + var lastTick time.Time + for { + select { + case t := <-ticker.C: + // Tick slip = our own scheduler is late picking up the tick + // (Go runtime contention, GC, etc.). Anything significantly + // past the budget means we're missing real-time deadlines + // before audio even leaves the box. + if !lastTick.IsZero() { + gap := t.Sub(lastTick) + if gap > tickBudget*2 { + log.Printf("audio ticker slip: %s gap between ticks (budget %s)", + gap.Round(time.Microsecond), tickBudget) + } + } + lastTick = t + + s.generateAndSendChunk() + case <-s.stopChan: + log.Printf("Audio streaming stopping") + return + } + } +} + +func (s *Server) generateAndSendChunk() { + t0 := time.Now() + // Timestamp invariants — do not weaken without re-analysis: + // 1. playbackTime is sampled fresh from the monotonic clock on every + // tick. It is NOT a running counter like `pending += chunkDurationUs`. + // 2. ChunkDurationMs × sampleRate must divide evenly by 1000 at every + // supported rate. At 20ms this holds: 44.1k→882, 48k→960, 88.2k→1764, + // 96k→1920. At 25ms, 44.1k→1102.5 (fractional) — do NOT change the + // constant without also re-working the chunk-size/sample math. + // Weakening either invariant re-introduces the drift class described in + // aiosendspin#217 (500ms cliff after ~17 minutes at 44.1k/25ms). See also + // issue #91 for converting the linear resampler to integer-rational math. + currentTime := s.getClockMicros() + playbackTime := currentTime + (BufferAheadMs * 1000) + + chunkSamples := (s.audioSource.SampleRate() * ChunkDurationMs) / 1000 + totalSamples := chunkSamples * s.audioSource.Channels() + + samples := make([]int32, totalSamples) + tRead := time.Now() + n, err := s.audioSource.Read(samples) + readDur := time.Since(tRead) + if err == nil && n == 0 { + // Source has nothing to emit right now (e.g., live-source + // idle while preset is paused). Skip without resetting the + // error counter; the audio engine wakes up on next tick. + return + } + if err != nil { + s.consecutiveReadErrs++ + // Log every error for the first few, then throttle + if s.consecutiveReadErrs <= 3 || s.consecutiveReadErrs%50 == 0 { + log.Printf("Error reading audio source (%d consecutive): %v", s.consecutiveReadErrs, err) + } + // After 1 second of failures (50 ticks at 20ms), notify clients + if s.consecutiveReadErrs == 50 { + log.Printf("Audio source failed for 1s, sending stream/end to all clients") + s.notifyStreamEnd() + } + return + } + s.consecutiveReadErrs = 0 + + s.clientsMu.RLock() + defer s.clientsMu.RUnlock() + + for _, c := range s.clients { + var audioData []byte + var encodeErr error + + c.mu.RLock() + codec := c.codec + opusEncoder := c.opusEncoder + flacEncoder := c.flacEncoder + resampler := c.resampler + tracker := c.bufferTracker + c.mu.RUnlock() + + switch codec { + case "opus": + if opusEncoder != nil { + samplesToEncode := samples[:n] + + // Resample when source rate != 48kHz (Opus is locked to 48kHz) + if resampler != nil { + outputSamples := resampler.OutputSamplesNeeded(len(samplesToEncode)) + resampled := make([]int32, outputSamples) + samplesWritten := resampler.Resample(samplesToEncode, resampled) + samplesToEncode = resampled[:samplesWritten] + } + + samples16 := convertToInt16(samplesToEncode) + audioData, encodeErr = opusEncoder.Encode(samples16) + if encodeErr != nil { + log.Printf("Opus encode error for %s: %v", c.name, encodeErr) + continue + } + } else { + continue + } + case "flac": + if flacEncoder != nil { + audioData, encodeErr = flacEncoder.Encode(samples[:n]) + if encodeErr != nil { + log.Printf("FLAC encode error for %s: %v", c.name, encodeErr) + continue + } + } else { + continue + } + case "pcm": + audioData = encodePCM(samples[:n]) + default: + audioData = encodePCM(samples[:n]) + } + + chunk := CreateAudioChunk(playbackTime, audioData) + + if tracker != nil { + chunkDurationUs := int64(ChunkDurationMs) * 1000 + tracker.PruneConsumed(currentTime) + if !tracker.CanSend(len(chunk), chunkDurationUs) { + log.Printf("Buffer full for %s, skipping chunk (%d bytes buffered, %dms)", + c.name, tracker.BufferedBytes(), tracker.BufferedDurationUs()/1000) + continue + } + } + + if err := c.SendBinary(chunk); err != nil { + log.Printf("Error sending audio to %s: %v", c.name, err) + continue + } + + if tracker != nil { + chunkDurationUs := int64(ChunkDurationMs) * 1000 + chunkEndTimeUs := playbackTime + chunkDurationUs + tracker.Register(chunkEndTimeUs, len(chunk), chunkDurationUs) + } + } + + total := time.Since(t0) + if total > time.Duration(ChunkDurationMs)*time.Millisecond { + log.Printf("chunk breakdown: total=%s read=%s rest=%s", + total.Round(time.Microsecond), + readDur.Round(time.Microsecond), + (total - readDur).Round(time.Microsecond), + ) + } +} + +func (s *Server) notifyStreamEnd() { + streamEnd := protocol.StreamEnd{ + Roles: []string{"player"}, + } + + s.clientsMu.RLock() + defer s.clientsMu.RUnlock() + + for _, c := range s.clients { + if c.HasRole("player") { + if err := c.Send("stream/end", streamEnd); err != nil { + log.Printf("Error sending stream/end to %s: %v", c.name, err) + } + } + } +} + +// negotiateCodec picks the best codec by scanning the client's advertised +// formats in order. The client controls preference (via --preferred-codec); +// the server accepts the first codec it can handle. +// +// Supported: pcm (at source rate), flac, opus. Falls back to pcm. +func negotiateCodec(c *ServerClient, sourceSampleRate int) string { + if c.capabilities == nil { + return "pcm" + } + + for _, format := range c.capabilities.SupportedFormats { + switch format.Codec { + case "pcm": + if format.SampleRate == sourceSampleRate && format.BitDepth == DefaultBitDepth { + return "pcm" + } + case "flac": + return "flac" + case "opus": + return "opus" + } + } + + return "pcm" +} + +func strPtr(s string) *string { + return &s +} + +// CreateAudioChunk packs timestamp + payload into a Sendspin binary frame: +// [1 byte message type][8 byte big-endian timestamp (µs)][audio bytes]. +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 +} + +// CreateArtworkChunk packs an artwork frame: [1 byte message type][8 byte timestamp (us)][image bytes]. +// Channel is 0-3, mapping to the artwork channel message types. +func CreateArtworkChunk(channel int, timestamp int64, imageData []byte) []byte { + chunk := make([]byte, protocol.BinaryMessageHeaderSize+len(imageData)) + chunk[0] = byte(protocol.ArtworkChannel0MessageType + channel) + binary.BigEndian.PutUint64(chunk[1:protocol.BinaryMessageHeaderSize], uint64(timestamp)) + copy(chunk[protocol.BinaryMessageHeaderSize:], imageData) + return chunk +} + +// convertToInt16 converts int32 samples to int16 (for Opus encoding) +func convertToInt16(samples []int32) []int16 { + result := make([]int16, len(samples)) + for i, s := range samples { + result[i] = int16(s >> 8) + } + return result +} + +// encodePCM encodes int32 samples as 24-bit PCM bytes +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 +} diff --git a/third_party/sendspin-go/pkg/sendspin/server_test.go b/third_party/sendspin-go/pkg/sendspin/server_test.go new file mode 100644 index 0000000..541343c --- /dev/null +++ b/third_party/sendspin-go/pkg/sendspin/server_test.go @@ -0,0 +1,985 @@ +// ABOUTME: Integration tests for Server API +// ABOUTME: Tests server creation, startup, client connections, and streaming +package sendspin + +import ( + "encoding/json" + "fmt" + "net" + "testing" + "time" + + "github.com/Sendspin/sendspin-go/pkg/protocol" + "github.com/gorilla/websocket" +) + +// waitForServerPort polls until server.Addr() returns the bound TCP port +// after Start runs with Port: 0. Fails the test if the listener isn't +// bound within 2s. +func waitForServerPort(t *testing.T, server *Server) int { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for server.Addr() == nil { + if time.Now().After(deadline) { + t.Fatal("server did not bind within 2s") + } + time.Sleep(10 * time.Millisecond) + } + return server.Addr().(*net.TCPAddr).Port +} + +func TestNewServer(t *testing.T) { + source := NewTestTone(48000, 2) + + tests := []struct { + name string + config ServerConfig + expectErr bool + }{ + { + name: "valid config", + config: ServerConfig{ + Port: 0, + Name: "Test Server", + Source: source, + }, + expectErr: false, + }, + { + name: "missing source", + config: ServerConfig{ + Port: 0, + Name: "Test Server", + }, + expectErr: true, + }, + { + name: "ephemeral port (omitted)", + config: ServerConfig{ + Name: "Test Server", + Source: source, + }, + expectErr: false, + }, + { + name: "default name", + config: ServerConfig{ + Port: 0, + Source: source, + }, + expectErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server, err := NewServer(tt.config) + + if tt.expectErr { + if err == nil { + t.Error("expected error, got nil") + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if server == nil { + t.Fatal("expected server to be created") + } + + // NewServer no longer substitutes a default Port — Port: 0 is + // honored as "let the OS pick" so tests bind ephemerally. + // Name still defaults when omitted. + if server.config.Name == "" { + t.Error("name should have been set to default") + } + }) + } +} + +func TestServerStartStop(t *testing.T) { + source := NewTestTone(48000, 2) + + server, err := NewServer(ServerConfig{ + Port: 0, + Name: "Test Server", + Source: source, + }) + if err != nil { + t.Fatalf("failed to create server: %v", err) + } + + // Start server in goroutine + errChan := make(chan error, 1) + go func() { + errChan <- server.Start() + }() + + // Wait for listener to bind, then give the rest of Start a moment. + waitForServerPort(t, server) + time.Sleep(100 * time.Millisecond) + + // Stop server + server.Stop() + + // Wait for server to stop + select { + case err := <-errChan: + if err != nil { + t.Errorf("server error: %v", err) + } + case <-time.After(5 * time.Second): + t.Error("server did not stop within timeout") + } +} + +func TestServerClientConnection(t *testing.T) { + source := NewTestTone(48000, 2) + + server, err := NewServer(ServerConfig{ + Port: 0, + Name: "Test Server", + Source: source, + Debug: true, + }) + if err != nil { + t.Fatalf("failed to create server: %v", err) + } + + // Start server + errChan := make(chan error, 1) + go func() { + errChan <- server.Start() + }() + + // Wait for listener to bind, then give the rest of Start a moment. + port := waitForServerPort(t, server) + time.Sleep(200 * time.Millisecond) + + // Connect as client + wsURL := fmt.Sprintf("ws://localhost:%d/sendspin", port) + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("failed to connect to server: %v", err) + } + defer conn.Close() + + // Send client/hello with versioned roles per spec + hello := protocol.Message{ + Type: "client/hello", + Payload: protocol.ClientHello{ + ClientID: "test-client-1", + Name: "Test Client", + Version: 1, + SupportedRoles: []string{"player@v1", "metadata@v1"}, + PlayerV1Support: &protocol.PlayerV1Support{ + SupportedFormats: []protocol.AudioFormat{ + { + Codec: "pcm", + Channels: 2, + SampleRate: 48000, + BitDepth: 24, + }, + }, + BufferCapacity: 1048576, + SupportedCommands: []string{"volume", "mute"}, + }, + }, + } + + if err := conn.WriteJSON(hello); err != nil { + t.Fatalf("failed to send hello: %v", err) + } + + // Read server/hello response + var msg protocol.Message + if err := conn.ReadJSON(&msg); err != nil { + t.Fatalf("failed to read server hello: %v", err) + } + + if msg.Type != "server/hello" { + t.Errorf("expected server/hello, got %s", msg.Type) + } + + // Parse server hello + helloData, _ := json.Marshal(msg.Payload) + var serverHello protocol.ServerHello + if err := json.Unmarshal(helloData, &serverHello); err != nil { + t.Fatalf("failed to unmarshal server hello: %v", err) + } + + if serverHello.Name != "Test Server" { + t.Errorf("expected server name 'Test Server', got %s", serverHello.Name) + } + + // Verify active_roles is present per spec + if len(serverHello.ActiveRoles) == 0 { + t.Error("expected active_roles to be set") + } + + // Verify connection_reason is present per spec + if serverHello.ConnectionReason == "" { + t.Error("expected connection_reason to be set") + } + + // Read the three control messages in any order. After the role + // dispatcher migration (M5), group/update comes from Group.addClient + // (synchronous) while stream/start and server/state come from role + // handlers dispatched asynchronously — so the order is not guaranteed. + // Audio binary chunks may also arrive interleaved with the control + // messages since the streaming ticker runs independently. + expectedMsgs := map[string]bool{ + "group/update": false, + "stream/start": false, + "server/state": false, + } + var firstAudioChunk []byte + controlCount := 0 + for controlCount < 3 { + msgType, rawData, err := conn.ReadMessage() + if err != nil { + t.Fatalf("failed to read message (have %d of 3 control msgs): %v", controlCount, err) + } + if msgType == websocket.BinaryMessage { + // Audio chunk arrived before all control messages; save the + // first one so we can validate it below. + if firstAudioChunk == nil { + firstAudioChunk = rawData + } + continue + } + if err := json.Unmarshal(rawData, &msg); err != nil { + t.Fatalf("failed to unmarshal control message: %v", err) + } + if _, ok := expectedMsgs[msg.Type]; !ok { + t.Fatalf("unexpected message type: %s", msg.Type) + } + expectedMsgs[msg.Type] = true + controlCount++ + } + for msgType, received := range expectedMsgs { + if !received { + t.Errorf("never received expected %s message", msgType) + } + } + + // Read audio chunk — either we already captured one above or read next. + var data []byte + if firstAudioChunk != nil { + data = firstAudioChunk + } else { + var readMsgType int + readMsgType, data, err = conn.ReadMessage() + if err != nil { + t.Fatalf("failed to read audio chunk: %v", err) + } + if readMsgType != websocket.BinaryMessage { + t.Errorf("expected binary message, got type %d", readMsgType) + } + } + + // Verify chunk format: [type:1][timestamp:8][audio_data:N] + if len(data) < 9 { + t.Errorf("audio chunk too small: %d bytes", len(data)) + } + + // Per spec: audio chunks use message type 4 (player role, slot 0) + if data[0] != AudioChunkMessageType { + t.Errorf("expected message type %d, got %d", AudioChunkMessageType, data[0]) + } + + // Check that clients list includes our client + clients := server.Clients() + if len(clients) != 1 { + t.Errorf("expected 1 client, got %d", len(clients)) + } + + if clients[0].ID != "test-client-1" { + t.Errorf("expected client ID 'test-client-1', got %s", clients[0].ID) + } + + // Close connection + conn.Close() + + // Give server time to handle disconnect + time.Sleep(100 * time.Millisecond) + + // Verify client was removed + clients = server.Clients() + if len(clients) != 0 { + t.Errorf("expected 0 clients after disconnect, got %d", len(clients)) + } + + // Stop server + server.Stop() + + select { + case <-errChan: + case <-time.After(5 * time.Second): + t.Error("server did not stop within timeout") + } +} + +func TestServerMultipleClients(t *testing.T) { + source := NewTestTone(48000, 2) + + server, err := NewServer(ServerConfig{ + Port: 0, + Name: "Test Server", + Source: source, + }) + if err != nil { + t.Fatalf("failed to create server: %v", err) + } + + // Start server + go server.Start() + port := waitForServerPort(t, server) + time.Sleep(200 * time.Millisecond) + + // Connect multiple clients + clients := make([]*websocket.Conn, 3) + wsURL := fmt.Sprintf("ws://localhost:%d/sendspin", port) + for i := 0; i < 3; i++ { + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("failed to connect client %d: %v", i, err) + } + clients[i] = conn + + // Send hello with versioned roles + hello := protocol.Message{ + Type: "client/hello", + Payload: protocol.ClientHello{ + ClientID: fmt.Sprintf("test-client-%d", i), + Name: fmt.Sprintf("Test Client %d", i), + Version: 1, + SupportedRoles: []string{"player@v1"}, + PlayerV1Support: &protocol.PlayerV1Support{ + SupportedFormats: []protocol.AudioFormat{ + { + Codec: "pcm", + Channels: 2, + SampleRate: 48000, + BitDepth: 24, + }, + }, + BufferCapacity: 1048576, + SupportedCommands: []string{"volume", "mute"}, + }, + }, + } + + if err := conn.WriteJSON(hello); err != nil { + t.Fatalf("failed to send hello from client %d: %v", i, err) + } + + // Read server/hello + var msg protocol.Message + if err := conn.ReadJSON(&msg); err != nil { + t.Fatalf("failed to read server hello for client %d: %v", i, err) + } + } + + // Give server time to register all clients + time.Sleep(100 * time.Millisecond) + + // Check that all clients are registered + serverClients := server.Clients() + if len(serverClients) != 3 { + t.Errorf("expected 3 clients, got %d", len(serverClients)) + } + + // Close all connections + for i, conn := range clients { + if err := conn.Close(); err != nil { + t.Errorf("failed to close client %d: %v", i, err) + } + } + + // Give server time to handle disconnects + time.Sleep(100 * time.Millisecond) + + // Verify all clients were removed + serverClients = server.Clients() + if len(serverClients) != 0 { + t.Errorf("expected 0 clients after disconnect, got %d", len(serverClients)) + } + + // Stop server + server.Stop() + time.Sleep(100 * time.Millisecond) +} + +// TestServer_GroupReceivesEventsFromRealHandshake is the real wiring +// guard for M2: it stands up an actual Server, subscribes to the +// default group BEFORE the server starts accepting connections, then +// drives a full WebSocket handshake via the gorilla client and asserts +// that ClientJoinedEvent and ClientLeftEvent are delivered through the +// event bus. A future refactor that moved defaultGroup.addClient out of +// handleConnection (or routed it through a different Group) would fail +// this test, which is exactly the guarantee the helper-level test cannot +// provide. +func TestServer_GroupReceivesEventsFromRealHandshake(t *testing.T) { + server, err := NewServer(ServerConfig{ + Port: 0, + Name: "Group Wiring Test", + Source: NewTestTone(48000, 2), + }) + if err != nil { + t.Fatalf("NewServer: %v", err) + } + + // Subscribe BEFORE Start so the join event is guaranteed to land. + events, unsubscribe := server.Group().Subscribe() + defer unsubscribe() + + errChan := make(chan error, 1) + go func() { errChan <- server.Start() }() + defer func() { + server.Stop() + select { + case <-errChan: + case <-time.After(5 * time.Second): + t.Error("server did not stop within timeout") + } + }() + + port := waitForServerPort(t, server) + time.Sleep(200 * time.Millisecond) + + conn, _, err := websocket.DefaultDialer.Dial(fmt.Sprintf("ws://localhost:%d/sendspin", port), nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + + hello := protocol.Message{ + Type: "client/hello", + Payload: protocol.ClientHello{ + ClientID: "wiring-test-client", + Name: "Wiring Test Client", + Version: 1, + SupportedRoles: []string{"player@v1"}, + PlayerV1Support: &protocol.PlayerV1Support{ + SupportedFormats: []protocol.AudioFormat{ + {Codec: "pcm", Channels: 2, SampleRate: 48000, BitDepth: 24}, + }, + BufferCapacity: 1048576, + }, + }, + } + if err := conn.WriteJSON(hello); err != nil { + conn.Close() + t.Fatalf("write hello: %v", err) + } + + // Wait for the ClientJoinedEvent delivered through the real wiring. + select { + case evt := <-events: + joined, ok := evt.(ClientJoinedEvent) + if !ok { + conn.Close() + t.Fatalf("first event = %T, want ClientJoinedEvent", evt) + } + if joined.Client.ID() != "wiring-test-client" { + t.Errorf("joined Client.ID() = %q, want %q", joined.Client.ID(), "wiring-test-client") + } + case <-time.After(2 * time.Second): + conn.Close() + t.Fatal("timed out waiting for ClientJoinedEvent from real handshake") + } + + // Disconnect and assert the matching ClientLeftEvent fires. + conn.Close() + + select { + case evt := <-events: + left, ok := evt.(ClientLeftEvent) + if !ok { + t.Fatalf("second event = %T, want ClientLeftEvent", evt) + } + if left.ClientID != "wiring-test-client" { + t.Errorf("left ClientID = %q, want %q", left.ClientID, "wiring-test-client") + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for ClientLeftEvent after disconnect") + } +} + +// TestServer_ControllerCommandEndToEnd exercises the full client/command +// pipeline: Server + ControllerGroupRole + real WebSocket handshake + +// client/command message → OnCommand callback fires. +func TestServer_ControllerCommandEndToEnd(t *testing.T) { + commandReceived := make(chan string, 1) + + server, err := NewServer(ServerConfig{ + Port: 0, + Name: "Controller Test", + Source: NewTestTone(48000, 2), + }) + if err != nil { + t.Fatalf("NewServer: %v", err) + } + + ctrl := NewControllerRole(ControllerConfig{ + SupportedCommands: []string{"next", "previous"}, + OnCommand: func(c *ServerClient, command string) { + commandReceived <- command + }, + }) + server.Group().RegisterRole(ctrl) + + errChan := make(chan error, 1) + go func() { errChan <- server.Start() }() + defer func() { + server.Stop() + select { + case <-errChan: + case <-time.After(5 * time.Second): + t.Error("server stop timeout") + } + }() + + port := waitForServerPort(t, server) + time.Sleep(200 * time.Millisecond) + + conn, _, err := websocket.DefaultDialer.Dial(fmt.Sprintf("ws://localhost:%d/sendspin", port), nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + + hello := protocol.Message{ + Type: "client/hello", + Payload: protocol.ClientHello{ + ClientID: "ctrl-test-client", + Name: "Controller Test Client", + Version: 1, + SupportedRoles: []string{"controller@v1"}, + }, + } + if err := conn.WriteJSON(hello); err != nil { + t.Fatalf("write hello: %v", err) + } + + // Drain the server/hello response. + var msg protocol.Message + if err := conn.ReadJSON(&msg); err != nil { + t.Fatalf("read server/hello: %v", err) + } + + // Give the join event time to dispatch to the controller role. + time.Sleep(100 * time.Millisecond) + + // Send a controller command. + cmd := protocol.Message{ + Type: "client/command", + Payload: map[string]interface{}{ + "controller": map[string]interface{}{ + "command": "next", + }, + }, + } + if err := conn.WriteJSON(cmd); err != nil { + t.Fatalf("write client/command: %v", err) + } + + select { + case got := <-commandReceived: + if got != "next" { + t.Errorf("command = %q, want %q", got, "next") + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for OnCommand callback") + } +} + +// TestServer_ActivateRolesDefaultsToPlayerMetadata confirms backward +// compat: when ServerConfig.SupportedRoles is nil, activateRoles still +// accepts "player" and "metadata" and rejects unknown families. +func TestServer_ActivateRolesDefault(t *testing.T) { + s, err := NewServer(ServerConfig{ + Port: 0, + Name: "test", + Source: NewTestTone(48000, 2), + }) + if err != nil { + t.Fatalf("NewServer: %v", err) + } + + got := s.activateRoles([]string{"player@v1", "metadata@v1", "controller@v1", "artwork@v1"}) + + want := map[string]bool{"player@v1": true, "metadata@v1": true} + gotSet := make(map[string]bool) + for _, r := range got { + gotSet[r] = true + } + if len(gotSet) != len(want) { + t.Errorf("activateRoles default = %v, want player+metadata only", got) + } + for r := range want { + if !gotSet[r] { + t.Errorf("missing expected role %s in %v", r, got) + } + } +} + +// TestServer_ActivateRolesFromConfig confirms that explicitly listing +// roles in ServerConfig.SupportedRoles controls what gets activated. +// Note: NewServer always registers player and metadata GroupRoles on +// the default group, so those families are always active via the group +// registry regardless of SupportedRoles. This test verifies that +// SupportedRoles adds additional families (artwork) beyond those. +func TestServer_ActivateRolesFromConfig(t *testing.T) { + s, err := NewServer(ServerConfig{ + Port: 0, + Name: "test", + Source: NewTestTone(48000, 2), + SupportedRoles: []string{"player", "artwork"}, + }) + if err != nil { + t.Fatalf("NewServer: %v", err) + } + + got := s.activateRoles([]string{"player@v1", "metadata@v1", "artwork@v1"}) + + gotSet := make(map[string]bool) + for _, r := range got { + gotSet[r] = true + } + // metadata is active because NewServer registers MetadataGroupRole + // on the default group, which auto-activates it. + if !gotSet["metadata@v1"] { + t.Error("metadata should be active (registered via GroupRole in NewServer)") + } + if !gotSet["player@v1"] || !gotSet["artwork@v1"] { + t.Errorf("expected player+artwork, got %v", got) + } +} + +// TestServer_ActivateRolesFromGroupRegistry confirms that registering a +// GroupRole on the server's Group auto-activates that role family even +// when it's not in ServerConfig.SupportedRoles. +func TestServer_ActivateRolesFromGroupRegistry(t *testing.T) { + s, err := NewServer(ServerConfig{ + Port: 0, + Name: "test", + Source: NewTestTone(48000, 2), + }) + if err != nil { + t.Fatalf("NewServer: %v", err) + } + + // Register a controller role — should auto-activate "controller". + ctrl := NewControllerRole(ControllerConfig{ + SupportedCommands: []string{"next"}, + }) + s.Group().RegisterRole(ctrl) + + got := s.activateRoles([]string{"player@v1", "controller@v1"}) + + gotSet := make(map[string]bool) + for _, r := range got { + gotSet[r] = true + } + if !gotSet["player@v1"] { + t.Error("player should be active (default)") + } + if !gotSet["controller@v1"] { + t.Error("controller should be active (registered via GroupRole)") + } +} + +func TestServerDuplicateClientID(t *testing.T) { + source := NewTestTone(48000, 2) + + server, err := NewServer(ServerConfig{ + Port: 0, + Name: "Test Server", + Source: source, + }) + if err != nil { + t.Fatalf("failed to create server: %v", err) + } + + // Start server + go server.Start() + port := waitForServerPort(t, server) + time.Sleep(200 * time.Millisecond) + + // Connect first client + wsURL := fmt.Sprintf("ws://localhost:%d/sendspin", port) + conn1, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("failed to connect first client: %v", err) + } + defer conn1.Close() + + hello := protocol.Message{ + Type: "client/hello", + Payload: protocol.ClientHello{ + ClientID: "duplicate-id", + Name: "First Client", + Version: 1, + SupportedRoles: []string{"player@v1"}, + }, + } + + if err := conn1.WriteJSON(hello); err != nil { + t.Fatalf("failed to send hello: %v", err) + } + + // Read server/hello + var msg protocol.Message + if err := conn1.ReadJSON(&msg); err != nil { + t.Fatalf("failed to read server hello: %v", err) + } + + // Try to connect second client with same ID + conn2, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("failed to connect second client: %v", err) + } + defer conn2.Close() + + if err := conn2.WriteJSON(hello); err != nil { + t.Fatalf("failed to send hello from second client: %v", err) + } + + // Second client should be rejected - connection should close + // Try to read a message - should get an error + conn2.SetReadDeadline(time.Now().Add(1 * time.Second)) + err = conn2.ReadJSON(&msg) + if err == nil { + // If we got a message, it should be an error message + if msg.Type == "server/error" { + // This is expected + } else { + t.Errorf("expected error or connection close for duplicate ID, got message type: %s", msg.Type) + } + } + + // Verify only one client is registered + serverClients := server.Clients() + if len(serverClients) != 1 { + t.Errorf("expected 1 client, got %d", len(serverClients)) + } + + // Stop server + server.Stop() + time.Sleep(100 * time.Millisecond) +} + +// TestServer_FLACStreamNegotiation verifies that a client advertising +// FLAC as its preferred codec receives stream/start with codec="flac" +// and a valid codec_header, followed by binary FLAC audio chunks. +func TestServer_FLACStreamNegotiation(t *testing.T) { + s, err := NewServer(ServerConfig{ + Port: 0, + Name: "FLAC Test Server", + Source: NewTestTone(48000, 2), + }) + if err != nil { + t.Fatalf("NewServer: %v", err) + } + + errChan := make(chan error, 1) + go func() { errChan <- s.Start() }() + defer func() { + s.Stop() + select { + case <-errChan: + case <-time.After(5 * time.Second): + t.Error("server stop timeout") + } + }() + + port := waitForServerPort(t, s) + time.Sleep(200 * time.Millisecond) + + conn, _, err := websocket.DefaultDialer.Dial(fmt.Sprintf("ws://localhost:%d/sendspin", port), nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + + // Advertise FLAC as first format (simulates --preferred-codec flac) + hello := protocol.Message{ + Type: "client/hello", + Payload: protocol.ClientHello{ + ClientID: "flac-test-client", + Name: "FLAC Test Client", + Version: 1, + SupportedRoles: []string{"player@v1", "metadata@v1"}, + PlayerV1Support: &protocol.PlayerV1Support{ + SupportedFormats: []protocol.AudioFormat{ + {Codec: "flac", SampleRate: 48000, Channels: 2, BitDepth: 24}, + {Codec: "pcm", SampleRate: 48000, Channels: 2, BitDepth: 24}, + }, + BufferCapacity: 1048576, + }, + }, + } + if err := conn.WriteJSON(hello); err != nil { + t.Fatalf("write hello: %v", err) + } + + // Read messages looking for stream/start with FLAC codec. + // The order of messages after server/hello is non-deterministic + // (group/update, stream/start, server/state may interleave). + var foundStreamStart bool + var codecHeader string + deadline := time.Now().Add(5 * time.Second) + + for time.Now().Before(deadline) { + conn.SetReadDeadline(deadline) + msgType, data, err := conn.ReadMessage() + if err != nil { + t.Fatalf("read: %v", err) + } + + if msgType == websocket.BinaryMessage { + // Got a binary audio chunk before finding stream/start + if !foundStreamStart { + t.Fatal("received binary chunk before stream/start") + } + // Verify it's a valid audio chunk (at least header + some data) + if len(data) < 10 { + t.Errorf("audio chunk too small: %d bytes", len(data)) + } + t.Logf("Received FLAC audio chunk: %d bytes", len(data)) + break // Success — we got stream/start + at least one chunk + } + + // Text message — parse the envelope + var msg protocol.Message + if err := json.Unmarshal(data, &msg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if msg.Type == "stream/start" { + startData, _ := json.Marshal(msg.Payload) + var streamStart protocol.StreamStart + if err := json.Unmarshal(startData, &streamStart); err != nil { + t.Fatalf("unmarshal stream/start: %v", err) + } + + if streamStart.Player == nil { + t.Fatal("stream/start has no player info") + } + if streamStart.Player.Codec != "flac" { + t.Errorf("codec = %q, want flac", streamStart.Player.Codec) + } + if streamStart.Player.CodecHeader == "" { + t.Error("codec_header is empty — FLAC requires STREAMINFO") + } + if streamStart.Player.SampleRate != 48000 { + t.Errorf("sample_rate = %d, want 48000", streamStart.Player.SampleRate) + } + if streamStart.Player.BitDepth != 24 { + t.Errorf("bit_depth = %d, want 24", streamStart.Player.BitDepth) + } + + codecHeader = streamStart.Player.CodecHeader + foundStreamStart = true + t.Logf("stream/start received: codec=flac, codec_header=%d chars (base64)", len(codecHeader)) + } + } + + if !foundStreamStart { + t.Fatal("never received stream/start with FLAC") + } +} + +// TestServer_BufferTrackerLimitsChunks verifies that the server's +// BufferTracker prevents buffer overflow. A client advertising a tiny +// buffer_capacity should receive fewer chunks than one with a large +// capacity, because the server skips sends when the tracker is full. +func TestServer_BufferTrackerLimitsChunks(t *testing.T) { + s, err := NewServer(ServerConfig{ + Port: 0, + Name: "Buffer Test", + Source: NewTestTone(48000, 2), + Debug: true, + }) + if err != nil { + t.Fatalf("NewServer: %v", err) + } + + errChan := make(chan error, 1) + go func() { errChan <- s.Start() }() + defer func() { + s.Stop() + select { + case <-errChan: + case <-time.After(5 * time.Second): + t.Error("server stop timeout") + } + }() + + port := waitForServerPort(t, s) + time.Sleep(200 * time.Millisecond) + + // Connect with a small buffer capacity (20000 bytes). + // At 48kHz stereo 24-bit PCM, one 20ms chunk is ~5760 bytes of audio + // plus the 9-byte chunk header (~5769 total). So 20000 bytes fits ~3 + // chunks at a time. As playback time advances, PruneConsumed frees + // slots, but the tracker still throttles the send rate well below 50 + // chunks/second. + conn, _, err := websocket.DefaultDialer.Dial(fmt.Sprintf("ws://localhost:%d/sendspin", port), nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + + hello := protocol.Message{ + Type: "client/hello", + Payload: protocol.ClientHello{ + ClientID: "buffer-test-client", + Name: "Buffer Test Client", + Version: 1, + SupportedRoles: []string{"player@v1", "metadata@v1"}, + PlayerV1Support: &protocol.PlayerV1Support{ + SupportedFormats: []protocol.AudioFormat{ + {Codec: "pcm", SampleRate: 48000, Channels: 2, BitDepth: 24}, + }, + BufferCapacity: 20000, // Small — fits ~3 PCM chunks at a time + }, + }, + } + if err := conn.WriteJSON(hello); err != nil { + t.Fatalf("write hello: %v", err) + } + + // Read messages for 1 second and count binary chunks received. + binaryCount := 0 + deadline := time.Now().Add(1 * time.Second) + for time.Now().Before(deadline) { + conn.SetReadDeadline(deadline) + msgType, _, err := conn.ReadMessage() + if err != nil { + break + } + if msgType == websocket.BinaryMessage { + binaryCount++ + } + } + + // At 50 chunks/second for 1 second, an unlimited client would get ~50 chunks. + // With a 5000-byte capacity, the tracker should limit this significantly. + // The exact number depends on timing (PruneConsumed frees slots as + // playback time passes), but it should be well under 50. + t.Logf("Received %d binary chunks in 1s (unlimited would be ~50)", binaryCount) + + // We just verify the tracker is working — some chunks should arrive + // (the first one always fits), but far fewer than 50. + if binaryCount >= 50 { + t.Errorf("received %d chunks — buffer tracker doesn't seem to be limiting", binaryCount) + } + if binaryCount == 0 { + t.Error("received 0 chunks — at least the first should have been sent") + } +} diff --git a/third_party/sendspin-go/pkg/sendspin/source.go b/third_party/sendspin-go/pkg/sendspin/source.go new file mode 100644 index 0000000..320b695 --- /dev/null +++ b/third_party/sendspin-go/pkg/sendspin/source.go @@ -0,0 +1,102 @@ +// ABOUTME: Audio source abstraction for Sendspin streaming +// ABOUTME: Provides AudioSource interface and common implementations +package sendspin + +import ( + "fmt" + "math" + "sync" +) + +// AudioSource provides PCM audio samples for streaming +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 +} + +type TestToneSource struct { + sampleIndex uint64 + sampleMu sync.Mutex + frequency float64 + sampleRate int + channels int +} + +// NewTestTone creates a new test tone generator +// Generates a 440Hz sine wave at the specified sample rate and channels +func NewTestTone(sampleRate, channels int) *TestToneSource { + if sampleRate == 0 { + sampleRate = DefaultSampleRate + } + if channels == 0 { + channels = DefaultChannels + } + + return &TestToneSource{ + frequency: 440.0, // A4 note + sampleRate: sampleRate, + channels: channels, + } +} + +func (s *TestToneSource) Read(samples []int32) (int, error) { + s.sampleMu.Lock() + defer s.sampleMu.Unlock() + + numSamples := len(samples) / s.channels + + for i := 0; i < numSamples; i++ { + t := float64(s.sampleIndex+uint64(i)) / float64(s.sampleRate) + sample := math.Sin(2 * math.Pi * s.frequency * t) + + // Scale to 24-bit range; 50% amplitude avoids clipping on decode + const max24bit = 8388607 // 2^23 - 1 + pcmValue := int32(sample * max24bit * 0.5) + + for ch := 0; ch < s.channels; ch++ { + samples[i*s.channels+ch] = pcmValue + } + } + + s.sampleIndex += uint64(numSamples) + + return len(samples), nil +} + +func (s *TestToneSource) SampleRate() int { return s.sampleRate } +func (s *TestToneSource) Channels() int { return s.channels } +func (s *TestToneSource) Metadata() (string, string, string) { + return "Test Tone", "Sendspin", "Test Signal" +} +func (s *TestToneSource) Close() error { return nil } + +// FileSource streams audio from a file +// Note: This is a placeholder - users should use server.NewAudioSource() for file streaming +// which supports MP3, FLAC, and other formats +type FileSource struct { + // This is intentionally not implemented in the public API yet + // Users can use internal/server.NewAudioSource() for now + // TODO: Move file source implementations to pkg/audio/decode +} + +// NewFileSource creates an audio source from a file +// Supported formats: MP3, FLAC +// Returns an error if the file cannot be opened or decoded +func NewFileSource(path string) (AudioSource, error) { + // For now, we use the internal implementation + // TODO: Migrate file sources to pkg/audio/decode + return nil, fmt.Errorf("file source not implemented: use internal/server.NewAudioSource()") +} diff --git a/third_party/sendspin-go/pkg/sync/clock.go b/third_party/sendspin-go/pkg/sync/clock.go new file mode 100644 index 0000000..64f1234 --- /dev/null +++ b/third_party/sendspin-go/pkg/sync/clock.go @@ -0,0 +1,132 @@ +// ABOUTME: Clock synchronization using Kalman time filter +// ABOUTME: Tracks offset and drift between client and server clocks +package sync + +import ( + "log" + "sync" + "time" +) + +// ClockSync manages clock synchronization using a Kalman time filter. +type ClockSync struct { + mu sync.RWMutex + filter *TimeFilter + rtt int64 + quality Quality + lastSync time.Time + sampleCount int +} + +type Quality int + +const ( + QualityGood Quality = iota + QualityDegraded + QualityLost +) + +// Quality thresholds in microseconds of offset σ (filter.GetError()). +// QualityGood: filter has converged; sub-100µs sync uncertainty. +// QualityDegraded: filter still useful but uncertainty is large. +// QualityLost: no recent sync OR uncertainty so high the estimate is suspect. +const ( + qualityGoodMaxErrorUs = 100 + qualityDegradedMaxErrorUs = 5000 +) + +func qualityFromError(errUs int64) Quality { + switch { + case errUs < qualityGoodMaxErrorUs: + return QualityGood + case errUs < qualityDegradedMaxErrorUs: + return QualityDegraded + default: + return QualityLost + } +} + +func NewClockSync() *ClockSync { + return NewClockSyncWithConfig(DefaultTimeFilterConfig()) +} + +// NewClockSyncWithConfig creates a ClockSync with a custom filter configuration. +func NewClockSyncWithConfig(cfg TimeFilterConfig) *ClockSync { + return &ClockSync{ + filter: NewTimeFilter(cfg), + quality: QualityLost, + } +} + +// ProcessSyncResponse processes a server/time response. +// t1: client send (Unix µs), t2: server receive (server µs), +// t3: server send (server µs), t4: client receive (Unix µs) +func (cs *ClockSync) ProcessSyncResponse(t1, t2, t3, t4 int64) { + rtt := (t4 - t1) - (t3 - t2) + + cs.mu.Lock() + defer cs.mu.Unlock() + + cs.rtt = rtt + cs.lastSync = time.Now() + + // NTP-style offset and uncertainty. + measurement := ((t2 - t1) + (t3 - t4)) / 2 + maxError := rtt / 2 + + cs.filter.Update(measurement, maxError, t4) + cs.quality = qualityFromError(cs.filter.GetError()) + + cs.sampleCount++ + + if cs.sampleCount <= 5 { + filterErr := cs.filter.GetError() + log.Printf("Sync #%d: rtt=%dμs, offset=%dμs, error=%dμs", + cs.sampleCount, rtt, measurement, filterErr) + } +} + +func (cs *ClockSync) GetStats() (rtt int64, quality Quality) { + cs.mu.RLock() + defer cs.mu.RUnlock() + return cs.rtt, cs.quality +} + +// CheckQuality updates quality based on time since last sync +func (cs *ClockSync) CheckQuality() Quality { + cs.mu.Lock() + defer cs.mu.Unlock() + + if time.Since(cs.lastSync) > 5*time.Second { + cs.quality = QualityLost + } + + return cs.quality +} + +// ServerToLocalTime converts server timestamp (µs) to local wall clock time. +func (cs *ClockSync) ServerToLocalTime(serverTime int64) time.Time { + cs.mu.RLock() + defer cs.mu.RUnlock() + + if !cs.filter.Synced() { + return time.Unix(0, serverTime*1000) + } + + // server→client conversion gives us client Unix µs + clientMicros := cs.filter.ComputeClientTime(serverTime) + return time.UnixMicro(clientMicros) +} + +// ServerMicrosNow returns current time in server's reference frame (us). +// This is the instance method equivalent of the deprecated package-level ServerMicrosNow(). +func (cs *ClockSync) ServerMicrosNow() int64 { + cs.mu.RLock() + defer cs.mu.RUnlock() + + if !cs.filter.Synced() { + return time.Now().UnixMicro() + } + + return cs.filter.ComputeServerTime(time.Now().UnixMicro()) +} diff --git a/third_party/sendspin-go/pkg/sync/clock_test.go b/third_party/sendspin-go/pkg/sync/clock_test.go new file mode 100644 index 0000000..86e3102 --- /dev/null +++ b/third_party/sendspin-go/pkg/sync/clock_test.go @@ -0,0 +1,198 @@ +// ABOUTME: Tests for Kalman-filter-based clock synchronization +// ABOUTME: Tests RTT calculation, time conversion, quality tracking +package sync + +import ( + "testing" + "time" +) + +func TestRTTCalculation(t *testing.T) { + t1 := int64(1000000) + t2 := int64(2000) + t3 := int64(2500) + t4 := int64(1005000) + + cs := NewClockSync() + cs.ProcessSyncResponse(t1, t2, t3, t4) + + // RTT = (t4-t1) - (t3-t2) = 5000 - 500 = 4500µs + rtt, _ := cs.GetStats() + if rtt != 4500 { + t.Errorf("expected RTT 4500µs, got %dµs", rtt) + } +} + +func TestSyncEstablishment(t *testing.T) { + cs := NewClockSync() + + if cs.filter.Synced() { + t.Error("expected not synced initially") + } + + // One low-noise sample is enough to mark the filter Synced. + cs.ProcessSyncResponse(1_000_000, 500_000, 500_100, 1_000_200) + + if !cs.filter.Synced() { + t.Error("expected synced after first response") + } + + // Drive enough low-noise samples to converge to QualityGood. + for i := 1; i < 60; i++ { + t1 := int64(1_000_000 + i*100_000) + cs.ProcessSyncResponse(t1, t1+50, t1+150, t1+200) // ~200µs RTT + } + _, quality := cs.GetStats() + if quality != QualityGood { + t.Errorf("expected QualityGood after convergence, got %v", quality) + } +} + +func TestServerToLocalTimeConversion(t *testing.T) { + cs := NewClockSync() + + clientNow := time.Now().UnixMicro() + serverTime := int64(5000000) // 5s into server loop + + // Feed several samples to let the filter converge + for i := 0; i < 10; i++ { + ct := clientNow + int64(i*100000) // 100ms apart + st := serverTime + int64(i*100000) + cs.ProcessSyncResponse(ct-1000, st, st+50, ct) + } + + // Convert a server time 100ms in the future + futureServer := serverTime + 10*100000 + 100000 + localTime := cs.ServerToLocalTime(futureServer) + + expectedLocal := time.UnixMicro(clientNow + 10*100000 + 100000) + diff := localTime.Sub(expectedLocal).Microseconds() + + if diff < -50000 || diff > 50000 { + t.Errorf("time conversion off by %dµs", diff) + } +} + +func TestQualityTracking(t *testing.T) { + cs := NewClockSync() + + // Single noisy sample → high σ → not yet QualityGood. + cs.ProcessSyncResponse(1000000, 1000, 1100, 1025000) + _, quality := cs.GetStats() + if quality == QualityGood { + t.Errorf("expected non-Good quality on first sample, got %v", quality) + } + + // Drive enough low-noise samples to converge below the QualityGood threshold. + for i := 1; i < 60; i++ { + t1 := int64(1_000_000 + i*100_000) + cs.ProcessSyncResponse(t1, t1+50, t1+150, t1+200) // ~200µs RTT + } + _, quality = cs.GetStats() + if quality != QualityGood { + t.Errorf("expected QualityGood after convergence, got %v (filter err=%d)", + quality, cs.filter.GetError()) + } +} + +func TestQualityDegradation(t *testing.T) { + cs := NewClockSync() + + // Drive enough low-noise samples to reach QualityGood. + for i := 0; i < 60; i++ { + t1 := int64(1_000_000 + i*100_000) + cs.ProcessSyncResponse(t1, t1+50, t1+150, t1+200) // ~200µs RTT + } + + quality := cs.CheckQuality() + if quality != QualityGood { + t.Errorf("expected QualityGood initially, got %v", quality) + } + + cs.mu.Lock() + cs.lastSync = time.Now().Add(-6 * time.Second) + cs.mu.Unlock() + + quality = cs.CheckQuality() + if quality != QualityLost { + t.Errorf("expected QualityLost after 6s, got %v", quality) + } +} + +func TestClockSync_ServerMicrosNow(t *testing.T) { + cs := NewClockSync() + + // Before sync, should return roughly current Unix micros + now1 := cs.ServerMicrosNow() + unixNow := time.Now().UnixMicro() + if abs64(now1-unixNow) > 1000000 { + t.Errorf("before sync: expected ~%d, got %d", unixNow, now1) + } + + // After sync, should return server-frame time + cs.ProcessSyncResponse(1000, 500000, 500100, 1200) + now2 := cs.ServerMicrosNow() + if now2 == 0 { + t.Error("after sync: got zero") + } +} + +func TestNewClockSyncWithConfig(t *testing.T) { + cfg := DefaultTimeFilterConfig() + cfg.MaxErrorScale = 0.25 + + csDefault := NewClockSync() + csScaled := NewClockSyncWithConfig(cfg) + + const samples = 30 + for i := 0; i < samples; i++ { + t1 := int64(1_000_000 + i*100_000) + t2 := int64(500_000 + i*100_000) + t3 := t2 + 100 + t4 := t1 + 1000 // ~1ms RTT + csDefault.ProcessSyncResponse(t1, t2, t3, t4) + csScaled.ProcessSyncResponse(t1, t2, t3, t4) + } + + errDefault := csDefault.filter.GetError() + errScaled := csScaled.filter.GetError() + if !(errScaled < errDefault) { + t.Errorf("expected scaled (0.25) error < default (0.5); got scaled=%d default=%d", + errScaled, errDefault) + } +} + +func TestConcurrentAccess(t *testing.T) { + cs := NewClockSync() + + cs.ProcessSyncResponse(1000000, 1000, 1100, 1025000) + + done := make(chan bool, 10) + for i := 0; i < 10; i++ { + go func() { + for j := 0; j < 100; j++ { + cs.GetStats() + cs.CheckQuality() + cs.ServerMicrosNow() + cs.ServerToLocalTime(int64(j * 1000)) + cs.ProcessSyncResponse( + int64(1000000+j), int64(1000+j), + int64(1100+j), int64(1025000+j), + ) + } + done <- true + }() + } + + for i := 0; i < 10; i++ { + <-done + } + + rtt, quality := cs.GetStats() + if rtt <= 0 { + t.Error("invalid RTT after concurrent access") + } + if quality == QualityLost { + t.Error("unexpected QualityLost after concurrent access") + } +} diff --git a/third_party/sendspin-go/pkg/sync/doc.go b/third_party/sendspin-go/pkg/sync/doc.go new file mode 100644 index 0000000..65874ce --- /dev/null +++ b/third_party/sendspin-go/pkg/sync/doc.go @@ -0,0 +1,11 @@ +// ABOUTME: Clock synchronization using Kalman time filter +// ABOUTME: Provides NTP-style clock sync with offset and drift tracking +// +// Package sync provides clock synchronization for precise audio timing. +// +// Uses a two-dimensional Kalman filter to track both clock offset and drift +// between client and server, following the Sendspin time filter specification. +// NTP-style round-trip time measurements feed the filter for optimal estimation. +// +// Reference: https://github.com/Sendspin/time-filter +package sync diff --git a/third_party/sendspin-go/pkg/sync/timefilter.go b/third_party/sendspin-go/pkg/sync/timefilter.go new file mode 100644 index 0000000..32a0034 --- /dev/null +++ b/third_party/sendspin-go/pkg/sync/timefilter.go @@ -0,0 +1,239 @@ +// ABOUTME: Kalman filter for NTP-style time synchronization +// ABOUTME: Tracks clock offset and drift between client and server +package sync + +import ( + "math" + "sync" +) + +// TimeFilter is a two-dimensional Kalman filter that tracks clock offset and +// drift rate between client and server using NTP-style time messages. +// Implements the Sendspin time filter specification. +type TimeFilter struct { + mu sync.Mutex + + lastUpdate int64 + + offset float64 + drift float64 + + offsetCovariance float64 + offsetDriftCovariance float64 + driftCovariance float64 + + processVariance float64 + driftProcessVariance float64 + forgetVarianceFactor float64 + adaptiveForgettingCutoff float64 + driftSignificanceThresholdSq float64 + maxErrorScale float64 + + useDrift bool + count uint8 + minSamples uint8 +} + +// TimeFilterConfig holds configuration for the Kalman filter. +type TimeFilterConfig struct { + // ProcessStdDev is the standard deviation of offset process noise in µs. + ProcessStdDev float64 + // DriftProcessStdDev is the standard deviation of drift process noise in µs/s. + DriftProcessStdDev float64 + // ForgetFactor (>1) applied to covariances when large residuals are detected. + ForgetFactor float64 + // AdaptiveCutoff is the fraction of max_error (0-1) that triggers forgetting. + AdaptiveCutoff float64 + // MinSamples before adaptive forgetting is enabled. + MinSamples uint8 + // DriftSignificanceThreshold is the SNR threshold for applying drift compensation. + DriftSignificanceThreshold float64 + // MaxErrorScale scales max_error before use as the measurement std dev. + // Spec recommends 0.5; values <1 indicate max_error overestimates noise. + MaxErrorScale float64 +} + +// DefaultTimeFilterConfig returns the canonical defaults from the upstream +// Sendspin/time-filter reference (PR #6, 2026-04-27). +func DefaultTimeFilterConfig() TimeFilterConfig { + return TimeFilterConfig{ + ProcessStdDev: 0.0, + DriftProcessStdDev: 1e-11, + ForgetFactor: 2.0, + AdaptiveCutoff: 3.0, + MinSamples: 100, + DriftSignificanceThreshold: 2.0, + MaxErrorScale: 0.5, + } +} + +// NewTimeFilter creates a Kalman filter for time synchronization. +func NewTimeFilter(cfg TimeFilterConfig) *TimeFilter { + maxErrorScale := cfg.MaxErrorScale + if maxErrorScale <= 0 { + maxErrorScale = 1.0 // avoid zero variance → div-by-zero in Kalman gain + } + tf := &TimeFilter{ + processVariance: cfg.ProcessStdDev * cfg.ProcessStdDev, + driftProcessVariance: cfg.DriftProcessStdDev * cfg.DriftProcessStdDev, + forgetVarianceFactor: cfg.ForgetFactor * cfg.ForgetFactor, + adaptiveForgettingCutoff: cfg.AdaptiveCutoff, + driftSignificanceThresholdSq: cfg.DriftSignificanceThreshold * cfg.DriftSignificanceThreshold, + maxErrorScale: maxErrorScale, + minSamples: cfg.MinSamples, + } + tf.reset() + return tf +} + +// Update processes a new time synchronization measurement. +// +// measurement: ((T2-T1)+(T3-T4))/2 in microseconds +// maxError: ((T4-T1)-(T3-T2))/2 in microseconds +// timeAdded: client timestamp when measurement was taken, in microseconds +func (tf *TimeFilter) Update(measurement, maxError, timeAdded int64) { + tf.mu.Lock() + defer tf.mu.Unlock() + + if timeAdded <= tf.lastUpdate { + return // skip non-monotonic timestamps + } + + dt := float64(timeAdded - tf.lastUpdate) + dtSq := dt * dt + tf.lastUpdate = timeAdded + + updateStdDev := float64(maxError) * tf.maxErrorScale + measVar := updateStdDev * updateStdDev + + // First measurement: establish offset baseline + if tf.count == 0 { + tf.count++ + tf.offset = float64(measurement) + tf.offsetCovariance = measVar + tf.drift = 0 + return + } + + // Second measurement: initial drift estimate via finite differences + if tf.count == 1 { + tf.count++ + tf.drift = (float64(measurement) - tf.offset) / dt + tf.offset = float64(measurement) + tf.driftCovariance = (tf.offsetCovariance + measVar) / dtSq + tf.offsetCovariance = measVar + return + } + + // --- Kalman prediction --- + predOffset := tf.offset + tf.drift*dt + + driftProcVar := dt * tf.driftProcessVariance + newDriftCov := tf.driftCovariance + driftProcVar + newOffsetDriftCov := tf.offsetDriftCovariance + tf.driftCovariance*dt + offsetProcVar := dt * tf.processVariance + newOffsetCov := tf.offsetCovariance + 2*tf.offsetDriftCovariance*dt + + tf.driftCovariance*dtSq + offsetProcVar + + // --- Innovation and adaptive forgetting --- + residual := float64(measurement) - predOffset + cutoff := float64(maxError) * tf.adaptiveForgettingCutoff + + if tf.count < tf.minSamples { + tf.count++ + } else if math.Abs(residual) > cutoff { + newDriftCov *= tf.forgetVarianceFactor + newOffsetDriftCov *= tf.forgetVarianceFactor + newOffsetCov *= tf.forgetVarianceFactor + } + + // --- Kalman update --- + invS := 1.0 / (newOffsetCov + measVar) + offsetGain := newOffsetCov * invS + driftGain := newOffsetDriftCov * invS + + tf.offset = predOffset + offsetGain*residual + tf.drift += driftGain * residual + + tf.driftCovariance = newDriftCov - driftGain*newOffsetDriftCov + tf.offsetDriftCovariance = newOffsetDriftCov - driftGain*newOffsetCov + tf.offsetCovariance = newOffsetCov - offsetGain*newOffsetCov + + driftSq := tf.drift * tf.drift + tf.useDrift = driftSq > tf.driftSignificanceThresholdSq*tf.driftCovariance +} + +// ComputeServerTime converts a client timestamp to server time. +func (tf *TimeFilter) ComputeServerTime(clientTime int64) int64 { + tf.mu.Lock() + defer tf.mu.Unlock() + + dt := float64(clientTime - tf.lastUpdate) + effectiveDrift := 0.0 + if tf.useDrift { + effectiveDrift = tf.drift + } + offset := math.Round(tf.offset + effectiveDrift*dt) + return clientTime + int64(offset) +} + +// ComputeClientTime converts a server timestamp to client time. +func (tf *TimeFilter) ComputeClientTime(serverTime int64) int64 { + tf.mu.Lock() + defer tf.mu.Unlock() + + effectiveDrift := 0.0 + if tf.useDrift { + effectiveDrift = tf.drift + } + return int64(math.Round( + (float64(serverTime) - tf.offset + effectiveDrift*float64(tf.lastUpdate)) / + (1.0 + effectiveDrift))) +} + +// GetError returns the estimated standard deviation of the offset in µs. +func (tf *TimeFilter) GetError() int64 { + tf.mu.Lock() + defer tf.mu.Unlock() + v := math.Sqrt(tf.offsetCovariance) + if math.IsInf(v, 0) || math.IsNaN(v) { + return math.MaxInt64 + } + return int64(math.Round(v)) +} + +// GetCovariance returns the offset variance in µs². Returns MaxInt64 before any update. +func (tf *TimeFilter) GetCovariance() int64 { + tf.mu.Lock() + defer tf.mu.Unlock() + v := tf.offsetCovariance + if math.IsInf(v, 0) || math.IsNaN(v) { + return math.MaxInt64 + } + return int64(math.Round(v)) +} + +// Synced returns true after at least one measurement has been processed. +func (tf *TimeFilter) Synced() bool { + tf.mu.Lock() + defer tf.mu.Unlock() + return tf.count > 0 +} + +// Reset clears all state. +func (tf *TimeFilter) Reset() { + tf.mu.Lock() + defer tf.mu.Unlock() + tf.reset() +} + +func (tf *TimeFilter) reset() { + tf.count = 0 + tf.offset = 0 + tf.drift = 0 + tf.offsetCovariance = math.Inf(1) + tf.offsetDriftCovariance = 0 + tf.driftCovariance = 0 + tf.lastUpdate = 0 + tf.useDrift = false +} diff --git a/third_party/sendspin-go/pkg/sync/timefilter_test.go b/third_party/sendspin-go/pkg/sync/timefilter_test.go new file mode 100644 index 0000000..1c56809 --- /dev/null +++ b/third_party/sendspin-go/pkg/sync/timefilter_test.go @@ -0,0 +1,321 @@ +// ABOUTME: Tests for Kalman filter time synchronization +// ABOUTME: Validates offset tracking, drift compensation, and adaptive forgetting +package sync + +import ( + "math" + "testing" +) + +func TestTimeFilterFirstMeasurement(t *testing.T) { + tf := NewTimeFilter(DefaultTimeFilterConfig()) + + tf.Update(5000, 100, 1000000) + + if !tf.Synced() { + t.Fatal("expected synced after first measurement") + } + + // After one sample, server_time = client_time + offset (≈5000) + st := tf.ComputeServerTime(1000000) + diff := st - (1000000 + 5000) + if abs64(diff) > 10 { + t.Errorf("expected server time near %d, got %d", 1000000+5000, st) + } +} + +func TestTimeFilterConvergesOnStableOffset(t *testing.T) { + tf := NewTimeFilter(DefaultTimeFilterConfig()) + + // Feed 50 measurements with constant offset=10000µs, low noise + for i := 0; i < 50; i++ { + clientTime := int64(1000000 + i*100000) // 100ms apart + tf.Update(10000, 50, clientTime) + } + + // Should converge close to 10000µs offset. + // With canonical MaxErrorScale=0.5 (¼ measurement variance vs legacy 1.0), + // observed final offset error is 0 µs after 50 stable samples. + clientNow := int64(1000000 + 50*100000) + st := tf.ComputeServerTime(clientNow) + offset := st - clientNow + if abs64(offset-10000) > 50 { + t.Errorf("expected offset near 10000, got %d", offset) + } +} + +func TestTimeFilterDriftTracking(t *testing.T) { + tf := NewTimeFilter(DefaultTimeFilterConfig()) + + // Simulate a clock drifting at 10µs per second (10ppm) + // Measurements taken 1s apart, offset increases by 10 each time + for i := 0; i < 200; i++ { + clientTime := int64(i) * 1000000 // 1s apart + trueOffset := int64(5000 + i*10) // drifting 10µs/s + tf.Update(trueOffset, 50, clientTime) + } + + // Check that drift-compensated conversion is more accurate than offset-only. + // With canonical DriftProcessStdDev=1e-11, drift is tracked exactly on this + // noise-free synthetic input — observed extrapolation error is 0 µs. + futureClient := int64(250 * 1000000) // 50s in the future + trueServerTime := futureClient + int64(5000+250*10) // true offset at t=250s + predicted := tf.ComputeServerTime(futureClient) + + err := abs64(predicted - trueServerTime) + if err > 200 { + t.Errorf("drift prediction error %dµs, expected <200µs", err) + } +} + +func TestTimeFilterAdaptiveForgetting(t *testing.T) { + tf := NewTimeFilter(DefaultTimeFilterConfig()) + + // Build up stable estimate at offset=5000 + for i := 0; i < 150; i++ { + clientTime := int64(1000000 + i*100000) + tf.Update(5000, 50, clientTime) + } + + // Sudden jump to offset=15000 (server clock adjusted) + jumpTime := int64(1000000 + 150*100000) + tf.Update(15000, 50, jumpTime) + + // After a few more samples at the new offset, should converge + for i := 151; i < 200; i++ { + clientTime := int64(1000000 + i*100000) + tf.Update(15000, 50, clientTime) + } + + // With canonical ForgetFactor=2.0 / AdaptiveCutoff=3.0 the filter recovers + // from the +10000 µs jump within ~50 samples; observed final error is ~2 µs. + clientNow := int64(1000000 + 200*100000) + st := tf.ComputeServerTime(clientNow) + offset := st - clientNow + if abs64(offset-15000) > 200 { + t.Errorf("expected offset near 15000 after jump, got %d", offset) + } +} + +func TestTimeFilterComputeClientTime(t *testing.T) { + tf := NewTimeFilter(DefaultTimeFilterConfig()) + + for i := 0; i < 20; i++ { + clientTime := int64(1000000 + i*100000) + tf.Update(8000, 50, clientTime) + } + + // Round-trip: client→server→client should be identity (within rounding) + clientTime := int64(5000000) + serverTime := tf.ComputeServerTime(clientTime) + backToClient := tf.ComputeClientTime(serverTime) + + if abs64(backToClient-clientTime) > 2 { + t.Errorf("round-trip error: %d → %d → %d", clientTime, serverTime, backToClient) + } +} + +func TestTimeFilterRejectsNonMonotonic(t *testing.T) { + tf := NewTimeFilter(DefaultTimeFilterConfig()) + + tf.Update(5000, 100, 2000000) + tf.Update(5000, 100, 1000000) // earlier timestamp, should be ignored + + // Only one sample counted + tf.mu.Lock() + count := tf.count + tf.mu.Unlock() + if count != 1 { + t.Errorf("expected count=1 after non-monotonic update, got %d", count) + } +} + +func TestTimeFilterReset(t *testing.T) { + tf := NewTimeFilter(DefaultTimeFilterConfig()) + + tf.Update(5000, 100, 1000000) + if !tf.Synced() { + t.Fatal("expected synced") + } + + tf.Reset() + if tf.Synced() { + t.Fatal("expected not synced after reset") + } +} + +func TestTimeFilterGetError(t *testing.T) { + tf := NewTimeFilter(DefaultTimeFilterConfig()) + + // Before any measurements, covariance is Inf → error should be large + err0 := tf.GetError() + if err0 < 1000000 { + t.Errorf("expected very large error before sync, got %d", err0) + } + + // After many low-noise measurements, error should be small + for i := 0; i < 100; i++ { + tf.Update(5000, 20, int64(1000000+i*100000)) + } + err1 := tf.GetError() + if err1 > 50 { + t.Errorf("expected small error after 100 samples, got %d", err1) + } +} + +func TestTimeFilterConcurrentAccess(t *testing.T) { + tf := NewTimeFilter(DefaultTimeFilterConfig()) + + done := make(chan bool, 10) + for i := 0; i < 10; i++ { + go func(id int) { + for j := 0; j < 100; j++ { + clientTime := int64(id*10000000 + j*100000) + tf.Update(5000, 50, clientTime) + tf.ComputeServerTime(clientTime) + tf.ComputeClientTime(clientTime + 5000) + tf.GetError() + tf.Synced() + } + done <- true + }(i) + } + for i := 0; i < 10; i++ { + <-done + } +} + +func abs64(x int64) int64 { + if x < 0 { + return -x + } + return x +} + +// Verify Inf covariance doesn't cause NaN propagation +func TestTimeFilterInitialInfCovariance(t *testing.T) { + tf := NewTimeFilter(DefaultTimeFilterConfig()) + + tf.mu.Lock() + if !math.IsInf(tf.offsetCovariance, 1) { + t.Error("expected +Inf initial offset covariance") + } + tf.mu.Unlock() + + // First update should produce finite values + tf.Update(5000, 100, 1000000) + tf.mu.Lock() + if math.IsInf(tf.offsetCovariance, 0) || math.IsNaN(tf.offsetCovariance) { + t.Errorf("expected finite covariance after first update, got %f", tf.offsetCovariance) + } + tf.mu.Unlock() +} + +func TestTimeFilterMaxErrorScaleDefault(t *testing.T) { + if got := DefaultTimeFilterConfig().MaxErrorScale; got != 0.5 { + t.Errorf("expected MaxErrorScale default 0.5, got %v", got) + } +} + +func TestTimeFilterMaxErrorScaleConvergence(t *testing.T) { + cfgDefault := DefaultTimeFilterConfig() // MaxErrorScale = 0.5 + cfgTighter := DefaultTimeFilterConfig() + cfgTighter.MaxErrorScale = 0.25 // half of default + + tfDefault := NewTimeFilter(cfgDefault) + tfTighter := NewTimeFilter(cfgTighter) + + for i := 0; i < 30; i++ { + clientTime := int64(1000000 + i*100000) + tfDefault.Update(5000, 50, clientTime) + tfTighter.Update(5000, 50, clientTime) + } + + if !(tfTighter.GetError() < tfDefault.GetError()) { + t.Errorf("expected tighter (0.25) error < default (0.5); got tighter=%d default=%d", + tfTighter.GetError(), tfDefault.GetError()) + } +} + +func TestTimeFilterGetCovariance(t *testing.T) { + tf := NewTimeFilter(DefaultTimeFilterConfig()) + + if got := tf.GetCovariance(); got != math.MaxInt64 { + t.Errorf("expected MaxInt64 before any update, got %d", got) + } + + for i := 0; i < 50; i++ { + tf.Update(5000, 20, int64(1000000+i*100000)) + } + + cov := tf.GetCovariance() + if cov <= 0 { + t.Errorf("expected positive covariance after convergence, got %d", cov) + } + if cov >= 50000 { + t.Errorf("expected covariance < 50000 µs² after convergence, got %d", cov) + } +} + +func TestTimeFilterRecoveryFromJump(t *testing.T) { + tf := NewTimeFilter(DefaultTimeFilterConfig()) + + // Build a stable estimate at offset=5000 over 150 samples. + var clientTime int64 = 1_000_000 + for i := 0; i < 150; i++ { + tf.Update(5000, 50, clientTime) + clientTime += 100_000 + } + + // Server clock jumps +30 ms. + tf.Update(35_000, 50, clientTime) + clientTime += 100_000 + + // Within 30 more samples, error should be < 100 µs. + for i := 0; i < 30; i++ { + tf.Update(35_000, 50, clientTime) + clientTime += 100_000 + } + + st := tf.ComputeServerTime(clientTime) + offset := st - clientTime + if abs64(offset-35_000) > 100 { + t.Errorf("expected offset within 100µs of 35000 after recovery, got %d", offset) + } +} + +func TestTimeFilterLongSoak(t *testing.T) { + if testing.Short() { + t.Skip("skipping long soak in -short mode") + } + + tf := NewTimeFilter(DefaultTimeFilterConfig()) + + // 60 minutes of measurements, 1 sample per second. + // Synthetic drift varies sinusoidally over the hour to simulate slow + // oscillator wander (peak 20 ppm = 20 µs/s). + const totalSeconds = 3600 + var clientTime int64 = 1_000_000 + var maxAbsError int64 + + for i := 0; i < totalSeconds; i++ { + // True drift in µs/s, sinusoid with 30-min period + truePpm := 20.0 * math.Sin(2*math.Pi*float64(i)/1800.0) + trueOffset := int64(5000 + float64(i)*truePpm) + tf.Update(trueOffset, 50, clientTime) + + // Measure the offset error after each step. + st := tf.ComputeServerTime(clientTime) + e := abs64(st - clientTime - trueOffset) + if e > maxAbsError { + maxAbsError = e + } + clientTime += 1_000_000 // +1 second + } + + // Bound: with 1e-11 drift process noise and 50 µs max_error, peak error + // should stay well under 5000 µs even with the changing drift. + if maxAbsError > 5000 { + t.Errorf("peak offset error during soak = %d µs, expected < 5000", maxAbsError) + } +} diff --git a/third_party/sendspin-go/scripts/quickstart-pi.sh b/third_party/sendspin-go/scripts/quickstart-pi.sh new file mode 100644 index 0000000..5b064d2 --- /dev/null +++ b/third_party/sendspin-go/scripts/quickstart-pi.sh @@ -0,0 +1,298 @@ +#!/usr/bin/env bash +# ABOUTME: One-shot installer for sendspin-player on 64-bit Raspberry Pi OS. +# ABOUTME: Fetches the latest arm64 release tarball, installs systemd unit, starts daemon. + +set -euo pipefail + +on_exit() { + local rc=$? + if [[ "${rc}" -ne 0 ]]; then + printf '\nIf install failed mid-way, re-running the script is safe — it is idempotent.\n' >&2 + fi +} +trap on_exit EXIT + +readonly REPO_OWNER="Sendspin" +readonly REPO_NAME="sendspin-go" +readonly REPO_URL="https://github.com/${REPO_OWNER}/${REPO_NAME}" +readonly RAW_URL_BASE="https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}" +readonly BINARY_NAME="sendspin-player" +readonly INSTALL_PATH="/usr/local/bin/${BINARY_NAME}" +readonly UNIT_PATH="/etc/systemd/system/${BINARY_NAME}.service" +readonly ENV_PATH="/etc/default/${BINARY_NAME}" +readonly CONFIG_DIR="/etc/sendspin" +readonly CONFIG_PATH="${CONFIG_DIR}/player.yaml" + +# Set by parse_args +ARG_NAME="" +ARG_DEVICE="" +ARG_VERSION="" +ARG_UNINSTALL=0 + +# Resolved by resolve_version +RESOLVED_TAG="" +RESOLVED_REF="" + +usage() { + cat <] [--device ] [--version ] [--uninstall] + +Installs sendspin-player as a systemd daemon on 64-bit Raspberry Pi OS. + +Options: + --name Friendly player name (default: -sendspin-player). + --device Exact audio device name. Run 'sendspin-player --list-audio-devices' after + install to discover available names. + --version Pin to a specific release tag (e.g. v1.6.2). Default: latest. + --uninstall Stop the service and remove the binary and unit file. Config is preserved. + -h, --help Show this help. + +Run with sudo: + curl -fsSL ${RAW_URL_BASE}/main/scripts/quickstart-pi.sh | sudo bash + curl -fsSL ${RAW_URL_BASE}/main/scripts/quickstart-pi.sh | sudo bash -s -- --name "Living Room" +EOF +} + +log() { printf '==> %s\n' "$*"; } +warn() { printf 'WARN: %s\n' "$*" >&2; } +die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --name) + [[ $# -ge 2 ]] || die "--name requires a value" + ARG_NAME="$2" + shift 2 + ;; + --device) + [[ $# -ge 2 ]] || die "--device requires a value" + ARG_DEVICE="$2" + shift 2 + ;; + --version) + [[ $# -ge 2 ]] || die "--version requires a value (e.g. v1.6.2)" + ARG_VERSION="$2" + shift 2 + ;; + --uninstall) + ARG_UNINSTALL=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + printf 'Unknown argument: %s\n\n' "$1" >&2 + usage >&2 + exit 2 + ;; + esac + done +} +preflight() { + if [[ "${EUID}" -ne 0 ]]; then + die "Root required. Re-run with sudo: + curl -fsSL ${RAW_URL_BASE}/main/scripts/quickstart-pi.sh | sudo bash" + fi + + local arch + arch="$(uname -m)" + if [[ "${arch}" != "aarch64" ]]; then + die "Unsupported architecture: ${arch}. This script supports 64-bit +Raspberry Pi OS only (aarch64). For Pi 3 / 4 / 5 / Zero 2 W, install +the 64-bit Pi OS image: https://www.raspberrypi.com/software/operating-systems/ +Pi 1 / Zero (v1) / Zero W are not supported (32-bit ARMv6 only)." + fi + + if [[ ! -f /etc/debian_version ]]; then + die "Unsupported OS. This script targets Debian-based distros (Pi OS, +Raspberry Pi OS Lite). For other distros see the README install steps: + ${REPO_URL}#installation" + fi + + if ! command -v systemctl >/dev/null 2>&1; then + die "systemctl not found. The quickstart installs sendspin-player as a +systemd service; non-systemd hosts must follow the manual install steps." + fi +} +do_uninstall() { + log "Uninstalling sendspin-player..." + + if systemctl list-unit-files "${BINARY_NAME}.service" >/dev/null 2>&1; then + systemctl disable --now "${BINARY_NAME}.service" 2>/dev/null || true + fi + + rm -f "${INSTALL_PATH}" + rm -f "${UNIT_PATH}" + systemctl daemon-reload + + log "Uninstall complete." + log "Config preserved at ${CONFIG_DIR}/ and ${ENV_PATH}." + log "Remove manually for a full purge:" + log " sudo rm -rf ${CONFIG_DIR} ${ENV_PATH}" +} +install_apt_deps() { + log "Installing runtime dependencies..." + DEBIAN_FRONTEND=noninteractive apt-get update -qq + # FLAC decode/encode is pure-Go (mewkiz/flac); the released binary is + # built with -tags=nolibopusfile so libopusfile is not linked either. + # apt resolves libasound2 to libasound2t64 automatically on time_t-64 + # distros (Trixie, Ubuntu 24.04). + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + libopus0 \ + libasound2 \ + ca-certificates \ + curl \ + tar +} +resolve_version() { + if [[ -n "${ARG_VERSION}" ]]; then + RESOLVED_TAG="${ARG_VERSION}" + RESOLVED_REF="${ARG_VERSION}" + log "Installing pinned version: ${RESOLVED_TAG}" + else + # Use GitHub's latest-release redirect: no API call, no JSON parsing. + # The redirect target tells us the resolved tag. + local redirect_url + redirect_url="$(curl -fsSLI -o /dev/null -w '%{url_effective}' \ + "${REPO_URL}/releases/latest")" \ + || die "Failed to resolve latest release tag from ${REPO_URL}/releases/latest" + RESOLVED_TAG="${redirect_url##*/}" + # The dist/ files at "main" are forward-compatible enough for the + # latest-tagged release; pin them to the same tag for consistency. + RESOLVED_REF="${RESOLVED_TAG}" + log "Installing latest release: ${RESOLVED_TAG}" + fi +} +stop_service() { + if systemctl is-active --quiet "${BINARY_NAME}.service"; then + log "Stopping running ${BINARY_NAME} service..." + systemctl stop "${BINARY_NAME}.service" + fi +} +install_binary() { + local tarball_url tarball_name binary_in_tarball tmpdir + binary_in_tarball="${BINARY_NAME}-linux-arm64" + tarball_name="${binary_in_tarball}.tar.gz" + tarball_url="${REPO_URL}/releases/download/${RESOLVED_TAG}/${tarball_name}" + + tmpdir="$(mktemp -d)" + # shellcheck disable=SC2064 + trap "rm -rf '${tmpdir}'; on_exit" EXIT + + log "Downloading ${tarball_url}..." + curl -fSL "${tarball_url}" -o "${tmpdir}/${tarball_name}" \ + || die "Failed to download release tarball from ${tarball_url}" + + log "Extracting..." + tar -xzf "${tmpdir}/${tarball_name}" -C "${tmpdir}" + + if [[ ! -f "${tmpdir}/${binary_in_tarball}" ]]; then + die "Tarball did not contain expected binary '${binary_in_tarball}'" + fi + + log "Installing ${INSTALL_PATH}..." + install -m 755 "${tmpdir}/${binary_in_tarball}" "${INSTALL_PATH}" +} +install_unit() { + local unit_url + unit_url="${RAW_URL_BASE}/${RESOLVED_REF}/dist/systemd/${BINARY_NAME}.service" + log "Installing systemd unit ${UNIT_PATH}..." + curl -fSL "${unit_url}" -o "${UNIT_PATH}" \ + || die "Failed to download unit file from ${unit_url}" + chmod 644 "${UNIT_PATH}" +} +# shell_quote: wrap a string in single quotes, escaping any embedded single +# quotes via the standard '\'' pattern. Safe for arbitrary user input. +shell_quote() { + local s="$1" + s="${s//\'/\'\\\'\'}" + printf "'%s'" "${s}" +} + +install_env() { + if [[ -n "${ARG_NAME}" || -n "${ARG_DEVICE}" ]]; then + log "Writing ${ENV_PATH} with --name/--device from flags..." + local opts="" + if [[ -n "${ARG_NAME}" ]]; then + opts+="--name $(shell_quote "${ARG_NAME}") " + fi + if [[ -n "${ARG_DEVICE}" ]]; then + opts+="--audio-device $(shell_quote "${ARG_DEVICE}") " + fi + # Trim trailing space. + opts="${opts% }" + cat >"${ENV_PATH}" <