Files
-rpi-sendspin/third_party/sendspin-go/docs/superpowers/plans/2026-04-14-drop-oto-backend.md

25 KiB

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
git checkout main
git pull --ff-only
git checkout -b feat/drop-oto
  • Step 0b: Verify baseline green before any changes
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:

// 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:

// 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
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
go test ./pkg/audio/output/

Expected: ok.

  • Step 6: Commit
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:

// 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:

// 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:

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:

func (p *Player) onStreamStart(format audio.Format) {
	if p.output == nil {
		p.output = output.NewMalgo()
	}
  • Step 3: Verify pkg/sendspin still builds
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
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

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:

// 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
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
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
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
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):

"github.com/Sendspin/sendspin-go/pkg/audio/output"

Change the Player struct field:

output          *player.Output

to:

output          output.Output
volume          int
muted           bool
  • Step 2: Update the New() constructor

Find:

return &Player{
    config:      config,
    clockSync:   clockSync,
    output:      player.NewOutput(),
    artwork:     artworkDL,
    ctx:         ctx,
    cancel:      cancel,
    playerState: "idle", // Start in idle state
}

Change to:

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:

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:

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:

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:

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:

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:

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:

// Apply to output
if p.output != nil {
    p.output.SetVolume(vol.Volume)
    p.output.SetMuted(vol.Muted)
}

Replace with:

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:

if p.output != nil {
    p.output.Close()
}

Replace with:

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
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:

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
go test ./... 2>&1 | tail -25

Expected: all packages pass.

  • Step 10: Commit
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

grep -rn "player\.Output\|player\.NewOutput" internal/ cmd/

Expected: no hits. If anything matches, resolve it before deleting.

  • Step 2: Delete the files
git rm internal/player/output.go internal/player/output_test.go
  • Step 3: Verify internal/player still builds (scheduler remains)
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
go build ./...
go test ./... 2>&1 | tail -25

Expected: all packages pass.

  • Step 5: Commit
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

go mod tidy
  • Step 2: Verify oto is gone from go.mod
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:

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

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)
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
git push -u origin feat/drop-oto
  • Step 2: Open the PR
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
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
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
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.