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